diff --git a/src/common/interceptors/api-version.interceptor.ts b/src/common/interceptors/api-version.interceptor.ts index a69203be..e974058f 100644 --- a/src/common/interceptors/api-version.interceptor.ts +++ b/src/common/interceptors/api-version.interceptor.ts @@ -70,7 +70,7 @@ export class ApiVersionInterceptor implements NestInterceptor { if (!path) return null; // Match /api/v1 or /v1 patterns - const match = path.match(/[/]v(\d+)(?:\.(\d+))?[/]/); + const match = path.match(/\/v(\d+)(?:\.(\d+))?\//); if (match) { const version: ApiVersion = { major: parseInt(match[1], 10), diff --git a/src/common/utils/websocket.utils.ts b/src/common/utils/websocket.utils.ts index c63c7b80..cbcd6e68 100644 --- a/src/common/utils/websocket.utils.ts +++ b/src/common/utils/websocket.utils.ts @@ -47,6 +47,9 @@ class WebSocketManager { this.connections.set(userId, new Set()); } + const userConnections = this.connections.get(userId); + if (!userConnections) { + return; const userConnections = this.connections.get(userId) || new Set(); // enforce global connection limits diff --git a/src/search/autocomplete/autocomplete.service.ts b/src/search/autocomplete/autocomplete.service.ts index 65a2929e..675a5cde 100644 --- a/src/search/autocomplete/autocomplete.service.ts +++ b/src/search/autocomplete/autocomplete.service.ts @@ -7,11 +7,18 @@ export class AutoCompleteService { constructor(private readonly elasticsearchService: ElasticsearchService) {} async getSuggestions(query: string): Promise { + const sanitizedQuery = (query ?? '').trim().slice(0, 100); + if (!sanitizedQuery) { + return []; + } + const result = await this.elasticsearchService.search({ index: COURSES_INDEX, + _source: false, + timeout: '1000ms', suggest: { title_suggest: { - text: query, + text: sanitizedQuery, completion: { field: 'title.suggest', skip_duplicates: true, diff --git a/src/search/filters/search-filters.service.ts b/src/search/filters/search-filters.service.ts index a40b00be..995f7193 100644 --- a/src/search/filters/search-filters.service.ts +++ b/src/search/filters/search-filters.service.ts @@ -10,6 +10,8 @@ export class SearchFiltersService { const result = await this.elasticsearchService.search({ index: COURSES_INDEX, size: 0, + _source: false, + timeout: '1500ms', aggs: { categories: { terms: { field: 'category', size: 50 } }, levels: { terms: { field: 'level', size: 10 } }, diff --git a/src/search/indexing/indexing.service.ts b/src/search/indexing/indexing.service.ts index 35b53139..fab2aac3 100644 --- a/src/search/indexing/indexing.service.ts +++ b/src/search/indexing/indexing.service.ts @@ -5,6 +5,7 @@ import { COURSES_INDEX, SEARCH_ANALYTICS_INDEX } from '../search.service'; @Injectable() export class IndexingService implements OnModuleInit { private readonly logger = new Logger(IndexingService.name); + private readonly reindexOnBoot = process.env.SEARCH_REINDEX_ON_BOOT === 'true'; constructor(private readonly elasticsearchService: ElasticsearchService) {} @@ -83,19 +84,99 @@ export class IndexingService implements OnModuleInit { // ── Index bootstrap ────────────────────────────────────────────────────────── private async ensureIndices() { - await Promise.all([this.createCoursesIndex(), this.createSearchAnalyticsIndex()]); + await Promise.all([ + this.createCoursesIndex(this.reindexOnBoot), + this.createSearchAnalyticsIndex(), + ]); } - async createCoursesIndex() { + async createCoursesIndex(forceReindex = false) { const exists = await this.elasticsearchService.indices.exists({ index: COURSES_INDEX }); - if (exists) return; + + if (exists) { + await this.ensureExistingCoursesIndexSettings(); + if (forceReindex) { + await this.reindexCoursesIndexWithCurrentMapping(); + } + return; + } this.logger.log(`Creating index: ${COURSES_INDEX}`); return this.elasticsearchService.indices.create({ index: COURSES_INDEX, + ...this.getCoursesIndexDefinition(), + }); + } + + private async ensureExistingCoursesIndexSettings() { + try { + await this.elasticsearchService.indices.putSettings({ + index: COURSES_INDEX, + settings: { + refresh_interval: '30s', + }, + }); + } catch (error) { + this.logger.warn(`Failed to update settings for ${COURSES_INDEX}: ${String(error)}`); + } + } + + private async reindexCoursesIndexWithCurrentMapping() { + const tempIndex = `${COURSES_INDEX}_tmp_${Date.now()}`; + this.logger.log( + `SEARCH_REINDEX_ON_BOOT enabled, reindexing ${COURSES_INDEX} using temporary index ${tempIndex}`, + ); + + try { + await this.elasticsearchService.indices.create({ + index: tempIndex, + ...this.getCoursesIndexDefinition(), + }); + + const sourceCount = await this.elasticsearchService.count({ index: COURSES_INDEX }); + if (sourceCount.count > 0) { + await this.elasticsearchService.reindex({ + wait_for_completion: true, + refresh: true, + source: { index: COURSES_INDEX }, + dest: { index: tempIndex }, + }); + } + + await this.elasticsearchService.indices.delete({ index: COURSES_INDEX }); + await this.elasticsearchService.indices.create({ + index: COURSES_INDEX, + ...this.getCoursesIndexDefinition(), + }); + + const tempCount = await this.elasticsearchService.count({ index: tempIndex }); + if (tempCount.count > 0) { + await this.elasticsearchService.reindex({ + wait_for_completion: true, + refresh: true, + source: { index: tempIndex }, + dest: { index: COURSES_INDEX }, + }); + } + + this.logger.log(`Reindex completed successfully for ${COURSES_INDEX}`); + } catch (error) { + this.logger.error(`Failed to reindex ${COURSES_INDEX}: ${String(error)}`); + throw error; + } finally { + const tempExists = await this.elasticsearchService.indices.exists({ index: tempIndex }); + if (tempExists) { + await this.elasticsearchService.indices.delete({ index: tempIndex }); + } + } + } + + private getCoursesIndexDefinition() { + return { settings: { number_of_shards: 1, number_of_replicas: 1, + refresh_interval: '30s', analysis: { analyzer: { english_custom: { @@ -104,6 +185,12 @@ export class IndexingService implements OnModuleInit { filter: ['lowercase', 'english_stop', 'english_stemmer'], }, }, + normalizer: { + lowercase_normalizer: { + type: 'custom', + filter: ['lowercase'], + }, + }, filter: { english_stop: { type: 'stop', stopwords: '_english_' }, english_stemmer: { type: 'stemmer', language: 'english' }, @@ -119,14 +206,15 @@ export class IndexingService implements OnModuleInit { fields: { keyword: { type: 'keyword' }, suggest: { type: 'completion' }, + search: { type: 'search_as_you_type' }, }, }, description: { type: 'text', analyzer: 'english_custom' }, content: { type: 'text', analyzer: 'english_custom' }, - tags: { type: 'keyword' }, - category: { type: 'keyword' }, - level: { type: 'keyword' }, - language: { type: 'keyword' }, + tags: { type: 'keyword', normalizer: 'lowercase_normalizer' }, + category: { type: 'keyword', normalizer: 'lowercase_normalizer' }, + level: { type: 'keyword', normalizer: 'lowercase_normalizer' }, + language: { type: 'keyword', normalizer: 'lowercase_normalizer' }, price: { type: 'float' }, rating: { type: 'float' }, views: { type: 'integer' }, @@ -142,7 +230,7 @@ export class IndexingService implements OnModuleInit { updatedAt: { type: 'date' }, }, }, - }); + }; } async createSearchAnalyticsIndex() { diff --git a/src/search/search.controller.spec.ts b/src/search/search.controller.spec.ts new file mode 100644 index 00000000..327961d0 --- /dev/null +++ b/src/search/search.controller.spec.ts @@ -0,0 +1,56 @@ +import { BadRequestException } from '@nestjs/common'; +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; + +describe('SearchController', () => { + let controller: SearchController; + let searchService: jest.Mocked; + + beforeEach(() => { + searchService = { + performSearch: jest.fn(), + getAutoComplete: jest.fn(), + getAvailableFilters: jest.fn(), + getSearchAnalytics: jest.fn(), + } as unknown as jest.Mocked; + + controller = new SearchController(searchService); + }); + + it('parses filters and pagination before calling search service', async () => { + searchService.performSearch.mockResolvedValueOnce({ + results: [], + total: 0, + page: 2, + limit: 10, + facets: { categories: [], levels: [], priceRanges: [] }, + }); + + await controller.search('nestjs', '{"category":"backend"}', 'relevance', '2', '10'); + + expect(searchService.performSearch).toHaveBeenCalledWith( + 'nestjs', + { category: 'backend' }, + 'relevance', + { page: 2, limit: 10 }, + ); + }); + + it('throws BadRequestException for invalid JSON filters', async () => { + await expect(controller.search('nestjs', '{bad-json}', 'relevance', '1', '20')).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws BadRequestException for invalid page', async () => { + await expect(controller.search('nestjs', '{}', 'relevance', '0', '20')).rejects.toThrow( + 'page must be a positive integer', + ); + }); + + it('throws BadRequestException for invalid limit', async () => { + await expect(controller.search('nestjs', '{}', 'relevance', '1', '100')).rejects.toThrow( + 'limit must be an integer between 1 and 50', + ); + }); +}); diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts index 7a78d7ca..da94924a 100644 --- a/src/search/search.controller.ts +++ b/src/search/search.controller.ts @@ -12,6 +12,8 @@ export class SearchController { @Query('q') query: string, @Query('filters') filters?: string, @Query('sort') sort?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, ) { let parsedFilters: Record = {}; if (filters) { @@ -21,7 +23,22 @@ export class SearchController { throw new BadRequestException('filters must be valid JSON'); } } - return this.searchService.performSearch(query, parsedFilters, sort); + + const parsedPage = page ? Number.parseInt(page, 10) : 1; + const parsedLimit = limit ? Number.parseInt(limit, 10) : 20; + + if (!Number.isInteger(parsedPage) || parsedPage < 1) { + throw new BadRequestException('page must be a positive integer'); + } + + if (!Number.isInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > 50) { + throw new BadRequestException('limit must be an integer between 1 and 50'); + } + + return this.searchService.performSearch(query, parsedFilters, sort, { + page: parsedPage, + limit: parsedLimit, + }); } @Get('autocomplete') diff --git a/src/search/search.service.spec.ts b/src/search/search.service.spec.ts new file mode 100644 index 00000000..76b9c636 --- /dev/null +++ b/src/search/search.service.spec.ts @@ -0,0 +1,128 @@ +import { SearchService } from './search.service'; +import { ElasticsearchService } from '@nestjs/elasticsearch'; +import { AutoCompleteService } from './autocomplete/autocomplete.service'; +import { SearchFiltersService } from './filters/search-filters.service'; +import { CachingService } from '../caching/caching.service'; + +describe('SearchService', () => { + let service: SearchService; + let elasticsearchService: jest.Mocked; + let cachingService: jest.Mocked; + let autoCompleteService: jest.Mocked; + let searchFiltersService: jest.Mocked; + + beforeEach(() => { + elasticsearchService = { + search: jest.fn(), + index: jest.fn(), + } as unknown as jest.Mocked; + elasticsearchService.index.mockResolvedValue({} as any); + + cachingService = { + getOrSet: jest.fn(async (_key: string, factory: () => Promise) => factory()), + } as unknown as jest.Mocked; + + autoCompleteService = { + getSuggestions: jest.fn(), + } as unknown as jest.Mocked; + + searchFiltersService = { + getFilters: jest.fn(), + } as unknown as jest.Mocked; + + service = new SearchService( + elasticsearchService, + autoCompleteService, + searchFiltersService, + cachingService, + ); + }); + + it('sanitizes input, normalizes filters, and applies bounded pagination', async () => { + elasticsearchService.search.mockResolvedValueOnce({ + hits: { + total: { value: 1 }, + hits: [ + { + _id: '1', + _score: 10, + _source: { title: 'NestJS Fundamentals' }, + highlight: { title: ['NestJS'] }, + }, + ], + }, + aggregations: { + categories: { buckets: [] }, + levels: { buckets: [] }, + price_ranges: { buckets: [] }, + }, + } as any); + + const result = await service.performSearch( + ' NestJS ', + { category: ' Backend ', language: ['EN', 'en', ''], price: { gte: 0, invalid: 'x' } }, + 'relevance', + { page: 0, limit: 100 }, + ); + + expect(result.page).toBe(1); + expect(result.limit).toBe(50); + + expect(elasticsearchService.search).toHaveBeenCalledTimes(1); + const esQuery = elasticsearchService.search.mock.calls[0][0] as any; + + expect(esQuery.from).toBe(0); + expect(esQuery.size).toBe(50); + expect(esQuery.timeout).toBe('1500ms'); + expect(esQuery.highlight).toBeDefined(); + expect(esQuery.query.function_score.query.bool.filter).toEqual( + expect.arrayContaining([ + { term: { category: 'backend' } }, + { terms: { language: ['en'] } }, + { range: { price: { gte: 0 } } }, + ]), + ); + }); + + it('supports filter-only search without highlight when query is empty', async () => { + elasticsearchService.search.mockResolvedValueOnce({ + hits: { + total: { value: 0 }, + hits: [], + }, + aggregations: { + categories: { buckets: [] }, + levels: { buckets: [] }, + price_ranges: { buckets: [] }, + }, + } as any); + + await service.performSearch(' ', { level: 'Beginner' }, 'relevance', { + page: 2, + limit: 10, + }); + + const esQuery = elasticsearchService.search.mock.calls[0][0] as any; + expect(esQuery.from).toBe(10); + expect(esQuery.size).toBe(10); + expect(esQuery.highlight).toBeUndefined(); + expect(esQuery.query.function_score.query).toEqual({ + bool: { + filter: [{ term: { level: 'beginner' } }], + }, + }); + }); + + it('normalizes autocomplete input before cache and downstream call', async () => { + autoCompleteService.getSuggestions.mockResolvedValueOnce(['nestjs']); + + await service.getAutoComplete(' nestjs '); + + expect(cachingService.getOrSet).toHaveBeenCalledWith( + expect.stringContaining('autocomplete:nestjs'), + expect.any(Function), + expect.any(Number), + ); + expect(autoCompleteService.getSuggestions).toHaveBeenCalledWith('nestjs'); + }); +}); diff --git a/src/search/search.service.ts b/src/search/search.service.ts index 082b4d3b..7585e10b 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -7,6 +7,38 @@ import { CACHE_TTL, CACHE_PREFIXES } from '../caching/caching.constants'; export const COURSES_INDEX = 'courses'; export const SEARCH_ANALYTICS_INDEX = 'search_analytics'; +const SEARCH_SOURCE_FIELDS = [ + 'id', + 'title', + 'description', + 'tags', + 'category', + 'level', + 'language', + 'price', + 'rating', + 'views', + 'enrollments', + 'duration', + 'instructorId', + 'instructorName', + 'status', + 'createdAt', + 'updatedAt', +]; + +type SearchOptions = { + page?: number; + limit?: number; +}; + +type SearchFilters = { + category?: string | string[]; + level?: string | string[]; + language?: string | string[]; + instructorId?: string; + price?: { gte?: number; lte?: number; gt?: number; lt?: number }; +}; @Injectable() export class SearchService { @@ -19,32 +51,34 @@ export class SearchService { private readonly cachingService: CachingService, ) {} - async performSearch(query: string, filters: any, sort?: string) { - const cacheKey = `${CACHE_PREFIXES.SEARCH}:${this.hashSearchParams(query, filters, sort)}`; + async performSearch(query: string, filters: any, sort?: string, options: SearchOptions = {}) { + const sanitizedQuery = (query ?? '').trim().slice(0, 200); + const page = Math.max(1, options.page ?? 1); + const limit = Math.min(50, Math.max(1, options.limit ?? 20)); + const from = (page - 1) * limit; + const normalizedFilters = this.normalizeFilters(filters); + const cacheKey = `${CACHE_PREFIXES.SEARCH}:${this.hashSearchParams( + sanitizedQuery, + normalizedFilters, + sort, + page, + limit, + )}`; + const hasQuery = sanitizedQuery.length > 0; return this.cachingService.getOrSet( cacheKey, async () => { const result = await this.elasticsearchService.search({ index: COURSES_INDEX, + from, + size: limit, + timeout: '1500ms', + track_total_hits: 10000, + _source: SEARCH_SOURCE_FIELDS, query: { function_score: { - query: { - bool: { - must: [ - { - multi_match: { - query, - fields: ['title^3', 'description^2', 'content', 'tags^2'], - type: 'best_fields' as const, - fuzziness: 'AUTO', - prefix_length: 1, - }, - }, - ], - filter: this.buildFilters(filters), - }, - }, + query: this.buildSearchQuery(sanitizedQuery, normalizedFilters, hasQuery), functions: [ { field_value_factor: { @@ -78,13 +112,14 @@ export class SearchService { }, }, sort: this.buildSort(sort), - track_total_hits: true, - highlight: { - fields: { - title: {}, - description: { fragment_size: 150, number_of_fragments: 1 }, - }, - }, + highlight: hasQuery + ? { + fields: { + title: {}, + description: { fragment_size: 150, number_of_fragments: 1 }, + }, + } + : undefined, aggs: { categories: { terms: { field: 'category' } }, levels: { terms: { field: 'level' } }, @@ -106,11 +141,13 @@ export class SearchService { const aggs = result.aggregations as any; const rankedResults = this.rankResults(hits); - this.logSearch(query, rankedResults.length, filters, sort); + this.logSearch(sanitizedQuery, rankedResults.length, normalizedFilters, sort); return { results: rankedResults, total, + page, + limit, facets: { categories: aggs?.categories?.buckets ?? [], levels: aggs?.levels?.buckets ?? [], @@ -123,11 +160,12 @@ export class SearchService { } async getAutoComplete(query: string) { - const cacheKey = `${CACHE_PREFIXES.SEARCH}:autocomplete:${query}`; + const sanitizedQuery = (query ?? '').trim().slice(0, 100); + const cacheKey = `${CACHE_PREFIXES.SEARCH}:autocomplete:${sanitizedQuery}`; return this.cachingService.getOrSet( cacheKey, - () => this.autoCompleteService.getSuggestions(query), + () => this.autoCompleteService.getSuggestions(sanitizedQuery), CACHE_TTL.SEARCH_RESULTS, ); } @@ -189,23 +227,78 @@ export class SearchService { private buildFilters(filters: any) { const esFilters: any[] = []; if (filters.category) { - esFilters.push({ term: { category: filters.category } }); + const category = this.normalizeKeywordValue(filters.category); + if (Array.isArray(category)) { + esFilters.push({ terms: { category } }); + } else if (category) { + esFilters.push({ term: { category } }); + } } if (filters.level) { - esFilters.push({ term: { level: filters.level } }); + const level = this.normalizeKeywordValue(filters.level); + if (Array.isArray(level)) { + esFilters.push({ terms: { level } }); + } else if (level) { + esFilters.push({ term: { level } }); + } } if (filters.price) { esFilters.push({ range: { price: filters.price } }); } if (filters.language) { - esFilters.push({ term: { language: filters.language } }); + const language = this.normalizeKeywordValue(filters.language); + if (Array.isArray(language)) { + esFilters.push({ terms: { language } }); + } else if (language) { + esFilters.push({ term: { language } }); + } } if (filters.instructorId) { - esFilters.push({ term: { instructorId: filters.instructorId } }); + const instructorId = this.normalizeString(filters.instructorId, false); + if (instructorId) { + esFilters.push({ term: { instructorId } }); + } } return esFilters; } + private buildSearchQuery(query: string, filters: any, hasQuery: boolean): Record { + if (!hasQuery) { + return { + bool: { + filter: this.buildFilters(filters), + }, + }; + } + + return { + bool: { + filter: this.buildFilters(filters), + should: [ + { + multi_match: { + query, + fields: ['title^3', 'description^2', 'content', 'tags^2'], + type: 'best_fields' as const, + operator: 'and' as const, + fuzziness: 'AUTO:4,7', + prefix_length: 1, + }, + }, + { + multi_match: { + query, + type: 'bool_prefix' as const, + fields: ['title.search', 'title.search._2gram', 'title.search._3gram'], + boost: 2, + }, + }, + ], + minimum_should_match: 1, + }, + }; + } + private buildSort(sort?: string) { if (sort === 'relevance') { return ['_score']; @@ -250,8 +343,14 @@ export class SearchService { }); } - private hashSearchParams(query: string, filters: any, sort?: string): string { - const str = `${query}:${JSON.stringify(filters)}:${sort ?? 'default'}`; + private hashSearchParams( + query: string, + filters: any, + sort?: string, + page = 1, + limit = 20, + ): string { + const str = `${query}:${JSON.stringify(filters)}:${sort ?? 'default'}:${page}:${limit}`; let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); @@ -260,4 +359,82 @@ export class SearchService { } return Math.abs(hash).toString(36); } + + private normalizeFilters(filters: any): SearchFilters { + const safeFilters = filters && typeof filters === 'object' ? filters : {}; + const normalized: SearchFilters = {}; + + const category = this.normalizeKeywordValue(safeFilters.category); + if (category) { + normalized.category = category; + } + + const level = this.normalizeKeywordValue(safeFilters.level); + if (level) { + normalized.level = level; + } + + const language = this.normalizeKeywordValue(safeFilters.language); + if (language) { + normalized.language = language; + } + + const instructorId = this.normalizeString(safeFilters.instructorId, false); + if (instructorId) { + normalized.instructorId = instructorId; + } + + const price = this.normalizePriceRange(safeFilters.price); + if (price) { + normalized.price = price; + } + + return normalized; + } + + private normalizeKeywordValue(value: unknown): string | string[] | null { + if (Array.isArray(value)) { + const normalized = value + .map((item) => this.normalizeString(item, true)) + .filter((item): item is string => !!item); + if (normalized.length === 0) { + return null; + } + return Array.from(new Set(normalized)).sort(); + } + + return this.normalizeString(value, true); + } + + private normalizeString(value: unknown, lowerCase: boolean): string | null { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim(); + if (!normalized) { + return null; + } + + return lowerCase ? normalized.toLowerCase() : normalized; + } + + private normalizePriceRange( + value: unknown, + ): { gte?: number; lte?: number; gt?: number; lt?: number } | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + const range = value as Record; + const normalized: { gte?: number; lte?: number; gt?: number; lt?: number } = {}; + for (const key of ['gte', 'lte', 'gt', 'lt'] as const) { + const currentValue = range[key]; + if (typeof currentValue === 'number' && Number.isFinite(currentValue)) { + normalized[key] = currentValue; + } + } + + return Object.keys(normalized).length > 0 ? normalized : null; + } }