Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
7b19f1e
chore: update package-lock.json to reflect changes in dependency vers…
Power70 Apr 23, 2026
f2b4800
refactor: improve code formatting and readability in audit-log.contro…
Power70 Apr 23, 2026
e181669
chore: update package-lock.json to reflect changes in dependency vers…
Power70 Apr 23, 2026
470666c
feat: enhance search functionality with pagination and improved query…
Power70 Apr 23, 2026
17920db
feat: add pagination support to search functionality with validation …
Power70 Apr 23, 2026
3bf1ccf
feat: enhance indexing service with improved reindexing method and El…
Power70 Apr 23, 2026
3f4449b
style: improve code formatting and readability in pagination utility …
Power70 Apr 23, 2026
a473ea7
style: improve code formatting and readability by restructuring metho…
Power70 Apr 23, 2026
f44734f
feat: enhance autocomplete service with query sanitization and improv…
Power70 Apr 23, 2026
77a7ffe
style: improve code formatting and readability across multiple files
Power70 Apr 23, 2026
b882711
feat: enhance search service with improved filter normalization and s…
Power70 Apr 23, 2026
da4b1d5
feat: implement reindexing on boot for courses index with enhanced in…
Power70 Apr 23, 2026
9e03255
feat: refactor request ID generation in LoggingInterceptor to use uti…
Power70 Apr 23, 2026
278342a
feat: add unit tests for SearchController and SearchService with inpu…
Power70 Apr 23, 2026
7db3d9d
Merge branch 'main' into feat/search-optimization
Power70 Apr 23, 2026
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: 1 addition & 1 deletion src/common/interceptors/api-version.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@
/**
* Extract version from URL path
*/
private extractFromPath(path: string): ApiVersion | null {

Check failure on line 69 in src/common/interceptors/api-version.interceptor.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

';' expected.

Check failure on line 69 in src/common/interceptors/api-version.interceptor.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

';' expected.

Check failure on line 69 in src/common/interceptors/api-version.interceptor.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

',' expected.

Check failure on line 69 in src/common/interceptors/api-version.interceptor.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

Declaration or statement expected.
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),
Expand Down Expand Up @@ -137,3 +137,3 @@
// This will be handled by the interceptor to inject the version
};
}
3 changes: 3 additions & 0 deletions src/common/utils/websocket.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
this.connections.set(userId, new Set());
}

const userConnections = this.connections.get(userId);
if (!userConnections) {
return;
const userConnections = this.connections.get(userId) || new Set<Socket>();

// enforce global connection limits
Expand All @@ -73,7 +76,7 @@
return true;
}

cleanupSocket(socket: Socket) {

Check failure on line 79 in src/common/utils/websocket.utils.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

';' expected.

Check failure on line 79 in src/common/utils/websocket.utils.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

',' expected.
const meta = this.meta.get(socket.id);
if (!meta) return;

Expand All @@ -88,7 +91,7 @@
this.meta.delete(socket.id);
}

getActiveConnections(userId: string): number {

Check failure on line 94 in src/common/utils/websocket.utils.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

Unexpected keyword or identifier.

Check failure on line 94 in src/common/utils/websocket.utils.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

';' expected.

Check failure on line 94 in src/common/utils/websocket.utils.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

',' expected.
return this.connections.get(userId)?.size || 0;
}

Expand Down
9 changes: 8 additions & 1 deletion src/search/autocomplete/autocomplete.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@ export class AutoCompleteService {
constructor(private readonly elasticsearchService: ElasticsearchService) {}

async getSuggestions(query: string): Promise<string[]> {
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,
Expand Down
2 changes: 2 additions & 0 deletions src/search/filters/search-filters.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
104 changes: 96 additions & 8 deletions src/search/indexing/indexing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}

Expand Down Expand Up @@ -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: {
Expand All @@ -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' },
Expand All @@ -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' },
Expand All @@ -142,7 +230,7 @@ export class IndexingService implements OnModuleInit {
updatedAt: { type: 'date' },
},
},
});
};
}

async createSearchAnalyticsIndex() {
Expand Down
56 changes: 56 additions & 0 deletions src/search/search.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<SearchService>;

beforeEach(() => {
searchService = {
performSearch: jest.fn(),
getAutoComplete: jest.fn(),
getAvailableFilters: jest.fn(),
getSearchAnalytics: jest.fn(),
} as unknown as jest.Mocked<SearchService>;

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',
);
});
});
19 changes: 18 additions & 1 deletion src/search/search.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {};
if (filters) {
Expand All @@ -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')
Expand Down
Loading
Loading