From 7b19f1e9d026eafaee640f41b17e64acbe2f3ce7 Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 17:36:00 +0100 Subject: [PATCH 01/14] chore: update package-lock.json to reflect changes in dependency versions and add new Redis modules --- package-lock.json | 246 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 229 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 60074ec9..64e63d14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2654,7 +2654,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -2667,7 +2667,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -4274,7 +4274,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -4295,7 +4295,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -6653,6 +6653,83 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "peer": true, + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -7469,28 +7546,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/babel__core": { @@ -8534,6 +8611,20 @@ "acorn": "^8" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -8548,7 +8639,7 @@ "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -8765,7 +8856,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argparse": { @@ -10468,7 +10559,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cron": { @@ -10730,7 +10821,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -11058,6 +11149,14 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -15702,7 +15801,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -17337,6 +17436,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -19229,7 +19345,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -19643,7 +19759,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -19831,7 +19947,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -19916,6 +20032,55 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/webpack": { + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, "node_modules/webpack-node-externals": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", @@ -19936,6 +20101,53 @@ "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -20235,7 +20447,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" From f2b4800370baee97539f873e593b0425b4cb83db Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 17:37:19 +0100 Subject: [PATCH 02/14] refactor: improve code formatting and readability in audit-log.controller.ts and auth.service.ts --- src/audit-log/audit-log.controller.ts | 60 +++++++++++++++++++++------ src/auth/auth.service.ts | 13 +++++- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/audit-log/audit-log.controller.ts b/src/audit-log/audit-log.controller.ts index 40e0609e..75386f31 100644 --- a/src/audit-log/audit-log.controller.ts +++ b/src/audit-log/audit-log.controller.ts @@ -12,7 +12,14 @@ import { ParseIntPipe, DefaultValuePipe, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiQuery, + ApiParam, + ApiBearerAuth, +} from '@nestjs/swagger'; import { Response } from 'express'; import { AuditLogService, AuditLogSearchFilters } from './audit-log.service'; import { AuditLog } from './audit-log.entity'; @@ -32,9 +39,21 @@ export class AuditLogController { @ApiOperation({ summary: 'Search audit logs with filters' }) @ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' }) @ApiQuery({ name: 'userEmail', required: false, description: 'Filter by user email' }) - @ApiQuery({ name: 'actions', required: false, description: 'Filter by actions (comma-separated)' }) - @ApiQuery({ name: 'categories', required: false, description: 'Filter by categories (comma-separated)' }) - @ApiQuery({ name: 'severities', required: false, description: 'Filter by severities (comma-separated)' }) + @ApiQuery({ + name: 'actions', + required: false, + description: 'Filter by actions (comma-separated)', + }) + @ApiQuery({ + name: 'categories', + required: false, + description: 'Filter by categories (comma-separated)', + }) + @ApiQuery({ + name: 'severities', + required: false, + description: 'Filter by severities (comma-separated)', + }) @ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type' }) @ApiQuery({ name: 'entityId', required: false, description: 'Filter by entity ID' }) @ApiQuery({ name: 'ipAddress', required: false, description: 'Filter by IP address' }) @@ -90,7 +109,12 @@ export class AuditLogController { @Get('recent') @ApiOperation({ summary: 'Get recent audit logs' }) - @ApiQuery({ name: 'limit', required: false, description: 'Number of logs to return', type: Number }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of logs to return', + type: Number, + }) @ApiResponse({ status: 200, description: 'Recent audit logs' }) async getRecent( @Query('limit', new DefaultValuePipe(100), ParseIntPipe) limit?: number, @@ -101,7 +125,12 @@ export class AuditLogController { @Get('user/:userId') @ApiOperation({ summary: 'Get audit logs for a specific user' }) @ApiParam({ name: 'userId', description: 'User ID' }) - @ApiQuery({ name: 'limit', required: false, description: 'Number of logs to return', type: Number }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of logs to return', + type: Number, + }) @ApiResponse({ status: 200, description: 'User audit logs' }) async getByUser( @Param('userId') userId: string, @@ -114,7 +143,12 @@ export class AuditLogController { @ApiOperation({ summary: 'Get audit logs for a specific entity' }) @ApiParam({ name: 'entityType', description: 'Entity type (e.g., user, course)' }) @ApiParam({ name: 'entityId', description: 'Entity ID' }) - @ApiQuery({ name: 'limit', required: false, description: 'Number of logs to return', type: Number }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of logs to return', + type: Number, + }) @ApiResponse({ status: 200, description: 'Entity audit logs' }) async getByEntity( @Param('entityType') entityType: string, @@ -127,7 +161,12 @@ export class AuditLogController { @Get('ip/:ipAddress') @ApiOperation({ summary: 'Get audit logs by IP address' }) @ApiParam({ name: 'ipAddress', description: 'IP address' }) - @ApiQuery({ name: 'limit', required: false, description: 'Number of logs to return', type: Number }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of logs to return', + type: Number, + }) @ApiResponse({ status: 200, description: 'IP audit logs' }) async getByIpAddress( @Param('ipAddress') ipAddress: string, @@ -148,10 +187,7 @@ export class AuditLogController { @ApiQuery({ name: 'startDate', required: true, description: 'Start date (ISO 8601)' }) @ApiQuery({ name: 'endDate', required: true, description: 'End date (ISO 8601)' }) @ApiResponse({ status: 200, description: 'Audit report' }) - async generateReport( - @Query('startDate') startDate: string, - @Query('endDate') endDate: string, - ) { + async generateReport(@Query('startDate') startDate: string, @Query('endDate') endDate: string) { if (!startDate || !endDate) { throw new HttpException('Start date and end date are required', HttpStatus.BAD_REQUEST); } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index c846d265..f2fe5889 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -71,7 +71,11 @@ export class AuthService { private readonly auditLogService: AuditLogService, ) {} - async register(registerDto: RegisterDto, ipAddress?: string, userAgent?: string): Promise { + async register( + registerDto: RegisterDto, + ipAddress?: string, + userAgent?: string, + ): Promise { return await this.transactionService.runInTransaction(async (_manager) => { // Create user const user = await this.usersService.create(registerDto); @@ -254,7 +258,12 @@ export class AuthService { } } - async logout(userId: string, sessionId?: string, ipAddress?: string, userAgent?: string): Promise<{ message: string }> { + async logout( + userId: string, + sessionId?: string, + ipAddress?: string, + userAgent?: string, + ): Promise<{ message: string }> { const user = await this.usersService.findOne(userId); await this.sessionService.withLock(`logout:${userId}`, async () => { From e181669ec1683a0b7e1c470e5e139ea2baf14df9 Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 17:41:43 +0100 Subject: [PATCH 03/14] chore: update package-lock.json to reflect changes in dependency versions and add new Redis modules --- src/search/filters/search-filters.service.ts | 2 ++ 1 file changed, 2 insertions(+) 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 } }, From 470666c335e9a4c94f461ec85bb82814302d059a Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 17:44:36 +0100 Subject: [PATCH 04/14] feat: enhance search functionality with pagination and improved query handling --- src/search/search.service.ts | 135 ++++++++++++++++++++++++----------- 1 file changed, 95 insertions(+), 40 deletions(-) diff --git a/src/search/search.service.ts b/src/search/search.service.ts index b8234245..65d3aefc 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -7,6 +7,30 @@ 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; +}; @Injectable() export class SearchService { @@ -19,32 +43,27 @@ 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 cacheKey = `${CACHE_PREFIXES.SEARCH}:${this.hashSearchParams(sanitizedQuery, filters, 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, filters, hasQuery), functions: [ { field_value_factor: { @@ -78,25 +97,21 @@ 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' } }, price_ranges: { range: { field: 'price', - ranges: [ - { to: 50 }, - { from: 50, to: 100 }, - { from: 100, to: 200 }, - { from: 200 }, - ], + ranges: [{ to: 50 }, { from: 50, to: 100 }, { from: 100, to: 200 }, { from: 200 }], }, }, }, @@ -111,11 +126,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, filters, sort); return { results: rankedResults, total, + page, + limit, facets: { categories: aggs?.categories?.buckets ?? [], levels: aggs?.levels?.buckets ?? [], @@ -211,6 +228,43 @@ export class SearchService { 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']; @@ -237,12 +291,7 @@ export class SearchService { })); } - private logSearch( - query: string, - resultsCount: number, - filters?: any, - sort?: string, - ): void { + private logSearch(query: string, resultsCount: number, filters?: any, sort?: string): void { // Fire-and-forget: analytics must not slow down or fail search responses this.elasticsearchService .index({ @@ -260,8 +309,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); From 17920dbbc88a15fdfc243bc849db9333539910ee Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 17:46:36 +0100 Subject: [PATCH 05/14] feat: add pagination support to search functionality with validation for page and limit parameters --- src/search/search.controller.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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') From 3bf1ccf3a799ab4c5bc9e5c5fcf045fe8c2d7fd2 Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 17:48:20 +0100 Subject: [PATCH 06/14] feat: enhance indexing service with improved reindexing method and Elasticsearch settings --- src/search/indexing/indexing.service.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/search/indexing/indexing.service.ts b/src/search/indexing/indexing.service.ts index 4f3b7a93..41c25ad6 100644 --- a/src/search/indexing/indexing.service.ts +++ b/src/search/indexing/indexing.service.ts @@ -61,7 +61,7 @@ export class IndexingService implements OnModuleInit { await this.elasticsearchService.delete({ index: COURSES_INDEX, id }); } - async reindexAll(courses: Record[]) { + async reindexAll(courses: Array>) { if (courses.length === 0) return; const operations = courses.flatMap((course) => { @@ -96,6 +96,7 @@ export class IndexingService implements OnModuleInit { settings: { number_of_shards: 1, number_of_replicas: 1, + refresh_interval: '30s', analysis: { analyzer: { english_custom: { @@ -104,6 +105,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 +126,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' }, From 3f4449b61e4ab6079fa9d8ad87cbbf185a812db7 Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 17:49:22 +0100 Subject: [PATCH 07/14] style: improve code formatting and readability in pagination utility file --- src/common/utils/pagination.util.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/common/utils/pagination.util.ts b/src/common/utils/pagination.util.ts index 4a640009..0b64a23c 100644 --- a/src/common/utils/pagination.util.ts +++ b/src/common/utils/pagination.util.ts @@ -1,6 +1,11 @@ import { BadRequestException } from '@nestjs/common'; import { SelectQueryBuilder } from 'typeorm'; -import { PaginationQueryDto, SortOrder, CursorPaginationQueryDto, CursorDirection } from '../dto/pagination.dto'; +import { + PaginationQueryDto, + SortOrder, + CursorPaginationQueryDto, + CursorDirection, +} from '../dto/pagination.dto'; export interface PaginatedResponse { data: T[]; From a473ea77e407668170936c4ca6082f03e89d1bdf Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 18:00:31 +0100 Subject: [PATCH 08/14] style: improve code formatting and readability by restructuring method signatures --- .../preferences/preferences.service.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/notifications/preferences/preferences.service.ts b/src/notifications/preferences/preferences.service.ts index a52ee17a..77fab88c 100644 --- a/src/notifications/preferences/preferences.service.ts +++ b/src/notifications/preferences/preferences.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { NotificationPreferences } from '../entities/notification-preferences.entity'; @@ -28,7 +28,10 @@ export class PreferencesService { /** * Update user preferences */ - async updatePreferences(userId: string, updateDto: Partial): Promise { + async updatePreferences( + userId: string, + updateDto: Partial, + ): Promise { const preferences = await this.getPreferences(userId); Object.assign(preferences, updateDto); return this.preferencesRepository.save(preferences); @@ -37,7 +40,10 @@ export class PreferencesService { /** * Check if a specific channel is enabled for a user */ - async isChannelEnabled(userId: string, channel: 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled'): Promise { + async isChannelEnabled( + userId: string, + channel: 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled', + ): Promise { const preferences = await this.getPreferences(userId); return !!preferences[channel]; } @@ -45,7 +51,10 @@ export class PreferencesService { /** * Toggle a specific channel for a user */ - async toggleChannel(userId: string, channel: 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled'): Promise { + async toggleChannel( + userId: string, + channel: 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled', + ): Promise { const preferences = await this.getPreferences(userId); preferences[channel] = !preferences[channel]; await this.preferencesRepository.save(preferences); From f44734f1501f5272c7cbe4eeafbe8ffe559728b8 Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 18:02:42 +0100 Subject: [PATCH 09/14] feat: enhance autocomplete service with query sanitization and improved Elasticsearch settings --- src/search/autocomplete/autocomplete.service.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/search/autocomplete/autocomplete.service.ts b/src/search/autocomplete/autocomplete.service.ts index 6f64f450..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, @@ -22,8 +29,6 @@ export class AutoCompleteService { }); const options = result.suggest?.title_suggest?.[0]?.options ?? []; - return Array.isArray(options) - ? options.map((option: any) => option.text as string) - : []; + return Array.isArray(options) ? options.map((option: any) => option.text as string) : []; } } From 77a7ffeb59a3ab99ee1d9ae52a85fa4254364d2d Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 18:07:22 +0100 Subject: [PATCH 10/14] style: improve code formatting and readability across multiple files --- src/audit-log/audit-log.module.ts | 17 ++-------- src/audit-log/decorators/audit.decorator.ts | 18 +++++++++-- .../interceptors/audit-log.interceptor.ts | 26 +++++---------- src/backup/backup.service.ts | 7 +++- .../interceptors/api-version.interceptor.ts | 8 ++--- src/common/utils/websocket.utils.ts | 7 ++-- .../processing/image-processing.service.ts | 11 +++---- .../validation/file-validation.constants.ts | 32 ++++++++++++------- .../validation/upload-progress.service.ts | 25 +++++++++------ .../scheduled-task-monitoring.service.ts | 15 ++++++--- src/users/users.module.ts | 8 +---- 11 files changed, 91 insertions(+), 83 deletions(-) diff --git a/src/audit-log/audit-log.module.ts b/src/audit-log/audit-log.module.ts index 5659510e..f3696692 100644 --- a/src/audit-log/audit-log.module.ts +++ b/src/audit-log/audit-log.module.ts @@ -9,20 +9,9 @@ import { AuditLogInterceptor } from './interceptors/audit-log.interceptor'; import { AuditRetentionTask } from './tasks/audit-retention.task'; @Module({ - imports: [ - TypeOrmModule.forFeature([AuditLog]), - ConfigModule, - ScheduleModule.forRoot(), - ], + imports: [TypeOrmModule.forFeature([AuditLog]), ConfigModule, ScheduleModule.forRoot()], controllers: [AuditLogController], - providers: [ - AuditLogService, - AuditLogInterceptor, - AuditRetentionTask, - ], - exports: [ - AuditLogService, - AuditLogInterceptor, - ], + providers: [AuditLogService, AuditLogInterceptor, AuditRetentionTask], + exports: [AuditLogService, AuditLogInterceptor], }) export class AuditLogModule {} diff --git a/src/audit-log/decorators/audit.decorator.ts b/src/audit-log/decorators/audit.decorator.ts index deb2dcab..0c3c2063 100644 --- a/src/audit-log/decorators/audit.decorator.ts +++ b/src/audit-log/decorators/audit.decorator.ts @@ -32,7 +32,11 @@ export const AuditCreate = (entityType: string, options?: Partial) => +export const AuditUpdate = ( + entityType: string, + entityIdParam: string, + options?: Partial, +) => AuditLog({ action: AuditAction.DATA_UPDATED, category: AuditCategory.DATA_MODIFICATION, @@ -41,7 +45,11 @@ export const AuditUpdate = (entityType: string, entityIdParam: string, options?: ...options, }); -export const AuditDelete = (entityType: string, entityIdParam: string, options?: Partial) => +export const AuditDelete = ( + entityType: string, + entityIdParam: string, + options?: Partial, +) => AuditLog({ action: AuditAction.DATA_DELETED, category: AuditCategory.DATA_MODIFICATION, @@ -51,7 +59,11 @@ export const AuditDelete = (entityType: string, entityIdParam: string, options?: ...options, }); -export const AuditView = (entityType: string, entityIdParam?: string, options?: Partial) => +export const AuditView = ( + entityType: string, + entityIdParam?: string, + options?: Partial, +) => AuditLog({ action: AuditAction.DATA_VIEWED, category: AuditCategory.DATA_ACCESS, diff --git a/src/audit-log/interceptors/audit-log.interceptor.ts b/src/audit-log/interceptors/audit-log.interceptor.ts index 1e9cdb82..388ffcd3 100644 --- a/src/audit-log/interceptors/audit-log.interceptor.ts +++ b/src/audit-log/interceptors/audit-log.interceptor.ts @@ -1,10 +1,4 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, - Logger, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { AuditLogService } from '../audit-log.service'; @@ -31,14 +25,7 @@ export class AuditLogInterceptor implements NestInterceptor { const response = context.switchToHttp().getResponse(); const startTime = Date.now(); - const { - method, - path, - ip, - headers, - user, - requestId, - } = request; + const { method, path, ip, headers, user, requestId } = request; const userAgent = headers['user-agent'] || 'Unknown'; const userId = user?.id || null; @@ -96,9 +83,12 @@ export class AuditLogInterceptor implements NestInterceptor { return; } - const severity = statusCode >= 500 ? AuditSeverity.ERROR : - statusCode >= 400 ? AuditSeverity.WARNING : - AuditSeverity.INFO; + const severity = + statusCode >= 500 + ? AuditSeverity.ERROR + : statusCode >= 400 + ? AuditSeverity.WARNING + : AuditSeverity.INFO; await this.auditLogService.log({ userId: userId || undefined, diff --git a/src/backup/backup.service.ts b/src/backup/backup.service.ts index 6f6cdc74..554e141f 100644 --- a/src/backup/backup.service.ts +++ b/src/backup/backup.service.ts @@ -199,7 +199,12 @@ export class BackupService { ); if (shouldRetry) { - this.scheduledTaskMonitoringService.recordRetry(taskName, attempt, maxAttempts - 1, errorMessage); + this.scheduledTaskMonitoringService.recordRetry( + taskName, + attempt, + maxAttempts - 1, + errorMessage, + ); await this.delay(this.scheduledTaskRetryDelayMs); continue; } diff --git a/src/common/interceptors/api-version.interceptor.ts b/src/common/interceptors/api-version.interceptor.ts index abd42be7..d61f82ce 100644 --- a/src/common/interceptors/api-version.interceptor.ts +++ b/src/common/interceptors/api-version.interceptor.ts @@ -32,7 +32,7 @@ export class ApiVersionInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const version = this.extractVersion(request); - + // Attach version to request (request as VersionedRequest).apiVersion = version; @@ -96,7 +96,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), @@ -202,7 +202,7 @@ export function ApiVersion(version: string): MethodDecorator { * Decorator to get the current API version from request */ export function GetApiVersion(): ParameterDecorator { - return function (target: object, propertyKey: string | symbol, parameterIndex: number) { + return function (_target: object, _propertyKey: string | symbol, _parameterIndex: number) { // This will be handled by the interceptor to inject the version }; -} \ No newline at end of file +} diff --git a/src/common/utils/websocket.utils.ts b/src/common/utils/websocket.utils.ts index 4807d2ae..39b8432c 100644 --- a/src/common/utils/websocket.utils.ts +++ b/src/common/utils/websocket.utils.ts @@ -46,7 +46,10 @@ class WebSocketManager { this.connections.set(userId, new Set()); } - const userConnections = this.connections.get(userId)!; + const userConnections = this.connections.get(userId); + if (!userConnections) { + return; + } // enforce max connections if (userConnections.size >= this.MAX_CONNECTIONS_PER_USER) { @@ -93,4 +96,4 @@ class WebSocketManager { } } -export const wsManager = new WebSocketManager(); \ No newline at end of file +export const wsManager = new WebSocketManager(); diff --git a/src/media/processing/image-processing.service.ts b/src/media/processing/image-processing.service.ts index 25e1d7d8..d5852f95 100644 --- a/src/media/processing/image-processing.service.ts +++ b/src/media/processing/image-processing.service.ts @@ -137,9 +137,8 @@ export class ImageProcessingService { const processedBuffer = await pipeline.toBuffer(); const processedMetadata = await sharp(processedBuffer).metadata(); - const compressionRatio = originalSize > 0 - ? ((originalSize - processedBuffer.length) / originalSize) * 100 - : 0; + const compressionRatio = + originalSize > 0 ? ((originalSize - processedBuffer.length) / originalSize) * 100 : 0; this.logger.log( `Image compressed: ${originalSize} -> ${processedBuffer.length} bytes (${compressionRatio.toFixed(1)}% reduction)`, @@ -162,7 +161,7 @@ export class ImageProcessingService { async generateThumbnails( buffer: Buffer, options?: { - sizes?: { name: string; width: number; height: number }[]; + sizes?: Array<{ name: string; width: number; height: number }>; format?: 'jpeg' | 'png' | 'webp'; quality?: number; }, @@ -328,9 +327,7 @@ export class ImageProcessingService { * Strip metadata from image (privacy/security) */ async stripMetadata(buffer: Buffer): Promise { - return sharp(buffer) - .withMetadata() - .toBuffer(); + return sharp(buffer).withMetadata().toBuffer(); } /** diff --git a/src/media/validation/file-validation.constants.ts b/src/media/validation/file-validation.constants.ts index 5c44c481..d015e21c 100644 --- a/src/media/validation/file-validation.constants.ts +++ b/src/media/validation/file-validation.constants.ts @@ -49,10 +49,7 @@ export const ALLOWED_FILE_TYPES = { ], // Archives (limited) - ARCHIVES: [ - 'application/zip', - 'application/x-zip-compressed', - ], + ARCHIVES: ['application/zip', 'application/x-zip-compressed'], } as const; export const ALLOWED_EXTENSIONS = { @@ -124,24 +121,35 @@ export const UPLOAD_PROGRESS_CONFIG = { export const MAGIC_NUMBERS: Record = { // Images - 'image/jpeg': [Buffer.from([0xFF, 0xD8, 0xFF])], - 'image/png': [Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])], - 'image/gif': [Buffer.from([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]), Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])], + 'image/jpeg': [Buffer.from([0xff, 0xd8, 0xff])], + 'image/png': [Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], + 'image/gif': [ + Buffer.from([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]), + Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), + ], 'image/webp': [Buffer.from([0x52, 0x49, 0x46, 0x46])], // RIFF header - 'image/bmp': [Buffer.from([0x42, 0x4D])], // BM - 'image/tiff': [Buffer.from([0x49, 0x49, 0x2A, 0x00]), Buffer.from([0x4D, 0x4D, 0x00, 0x2A])], + 'image/bmp': [Buffer.from([0x42, 0x4d])], // BM + 'image/tiff': [Buffer.from([0x49, 0x49, 0x2a, 0x00]), Buffer.from([0x4d, 0x4d, 0x00, 0x2a])], // PDF 'application/pdf': [Buffer.from([0x25, 0x50, 0x44, 0x46])], // %PDF // ZIP (also for docx, xlsx, pptx) - 'application/zip': [Buffer.from([0x50, 0x4B, 0x03, 0x04])], + 'application/zip': [Buffer.from([0x50, 0x4b, 0x03, 0x04])], // MP4 - 'video/mp4': [Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), Buffer.from([0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70])], + 'video/mp4': [ + Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), + Buffer.from([0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]), + ], // MP3 - 'audio/mpeg': [Buffer.from([0xFF, 0xFB]), Buffer.from([0xFF, 0xF3]), Buffer.from([0xFF, 0xF2]), Buffer.from([0x49, 0x44, 0x33])], + 'audio/mpeg': [ + Buffer.from([0xff, 0xfb]), + Buffer.from([0xff, 0xf3]), + Buffer.from([0xff, 0xf2]), + Buffer.from([0x49, 0x44, 0x33]), + ], // WAV 'audio/wav': [Buffer.from([0x52, 0x49, 0x46, 0x46])], // RIFF diff --git a/src/media/validation/upload-progress.service.ts b/src/media/validation/upload-progress.service.ts index 096322c4..0795e07f 100644 --- a/src/media/validation/upload-progress.service.ts +++ b/src/media/validation/upload-progress.service.ts @@ -4,7 +4,14 @@ import { UPLOAD_PROGRESS_CONFIG } from './file-validation.constants'; export interface UploadProgress { uploadId: string; - status: 'pending' | 'validating' | 'scanning' | 'processing' | 'uploading' | 'completed' | 'failed'; + status: + | 'pending' + | 'validating' + | 'scanning' + | 'processing' + | 'uploading' + | 'completed' + | 'failed'; progress: number; // 0-100 fileName: string; fileSize: number; @@ -181,8 +188,8 @@ export class UploadProgressService { } } - return uploads.sort((a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + return uploads.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), ); } catch (error) { this.logger.error('Failed to list active uploads:', error); @@ -214,8 +221,10 @@ export class UploadProgressService { const updatedAt = new Date(progress.updatedAt).getTime(); // Delete if old and completed/failed - if ((progress.status === 'completed' || progress.status === 'failed') && - (now - updatedAt > maxAgeMs)) { + if ( + (progress.status === 'completed' || progress.status === 'failed') && + now - updatedAt > maxAgeMs + ) { await this.redis.del(keys[i]); deletedCount++; } @@ -301,11 +310,7 @@ export class UploadProgressService { */ private async saveProgress(uploadId: string, progress: UploadProgress): Promise { const key = this.getRedisKey(uploadId); - await this.redis.setex( - key, - UPLOAD_PROGRESS_CONFIG.EXPIRY_SECONDS, - JSON.stringify(progress), - ); + await this.redis.setex(key, UPLOAD_PROGRESS_CONFIG.EXPIRY_SECONDS, JSON.stringify(progress)); } /** diff --git a/src/monitoring/scheduled-task-monitoring.service.ts b/src/monitoring/scheduled-task-monitoring.service.ts index af446c4f..5dcffda4 100644 --- a/src/monitoring/scheduled-task-monitoring.service.ts +++ b/src/monitoring/scheduled-task-monitoring.service.ts @@ -200,17 +200,21 @@ export class ScheduledTaskMonitoringService { const tasks = Array.from(this.taskConfigs.entries()).map(([taskName, config]) => { const history = this.executionHistory.get(taskName) || []; const lastExecution = history[history.length - 1] || null; - const lastSuccess = [...history].reverse().find((entry) => entry.status === 'SUCCESS') || null; + const lastSuccess = + [...history].reverse().find((entry) => entry.status === 'SUCCESS') || null; const lastFailure = - [...history].reverse().find((entry) => entry.status === 'FAILED' || entry.status === 'TIMED_OUT') || - null; + [...history] + .reverse() + .find((entry) => entry.status === 'FAILED' || entry.status === 'TIMED_OUT') || null; const activeCount = Array.from(this.activeExecutions.values()).filter( (entry) => entry.taskName === taskName, ).length; const threshold = config.expectedIntervalMs + (config.missedExecutionGraceMs || 0); const missed = - !!lastExecution && now.getTime() - lastExecution.startedAt.getTime() > threshold && activeCount === 0; + !!lastExecution && + now.getTime() - lastExecution.startedAt.getTime() > threshold && + activeCount === 0; const retryStats = this.retryStats.get(taskName) || { totalRetries: 0 }; @@ -234,7 +238,8 @@ export class ScheduledTaskMonitoringService { activeExecutions: this.activeExecutions.size, tasksWithMissedExecutions: tasks.filter((task) => task.missed).length, tasksWithRecentFailures: tasks.filter( - (task) => task.lastExecution?.status === 'FAILED' || task.lastExecution?.status === 'TIMED_OUT', + (task) => + task.lastExecution?.status === 'FAILED' || task.lastExecution?.status === 'TIMED_OUT', ).length, }, tasks, diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 0f12e68c..0372d320 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -19,13 +19,7 @@ import { BullModule.registerQueue({ name: 'user-data-export' }), ], controllers: [UsersController], - providers: [ - UsersService, - ExportService, - UserDataExportProcessor, - RolesGuard, - JwtAuthGuard, - ], + providers: [UsersService, ExportService, UserDataExportProcessor, RolesGuard, JwtAuthGuard], exports: [UsersService], }) export class UsersModule {} From b8827111e041a28ed3ee217eca2d3960d8a3ef76 Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 22:24:00 +0100 Subject: [PATCH 11/14] feat: enhance search service with improved filter normalization and sanitization Co-authored-by: Copilot --- src/search/search.service.ts | 130 ++++++++++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 9 deletions(-) diff --git a/src/search/search.service.ts b/src/search/search.service.ts index 65d3aefc..7585e10b 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -32,6 +32,14 @@ type SearchOptions = { 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 { private readonly logger = new Logger(SearchService.name); @@ -48,7 +56,14 @@ export class SearchService { 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 cacheKey = `${CACHE_PREFIXES.SEARCH}:${this.hashSearchParams(sanitizedQuery, filters, sort, page, 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( @@ -63,7 +78,7 @@ export class SearchService { _source: SEARCH_SOURCE_FIELDS, query: { function_score: { - query: this.buildSearchQuery(sanitizedQuery, filters, hasQuery), + query: this.buildSearchQuery(sanitizedQuery, normalizedFilters, hasQuery), functions: [ { field_value_factor: { @@ -126,7 +141,7 @@ export class SearchService { const aggs = result.aggregations as any; const rankedResults = this.rankResults(hits); - this.logSearch(sanitizedQuery, rankedResults.length, filters, sort); + this.logSearch(sanitizedQuery, rankedResults.length, normalizedFilters, sort); return { results: rankedResults, @@ -145,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, ); } @@ -211,19 +227,37 @@ 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; } @@ -325,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; + } } From da4b1d5e76e69a1c7f5a0039e10422242afa9464 Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 22:27:01 +0100 Subject: [PATCH 12/14] feat: implement reindexing on boot for courses index with enhanced index settings --- src/search/indexing/indexing.service.ts | 88 +++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/src/search/indexing/indexing.service.ts b/src/search/indexing/indexing.service.ts index 41c25ad6..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,16 +84,95 @@ 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, @@ -150,7 +230,7 @@ export class IndexingService implements OnModuleInit { updatedAt: { type: 'date' }, }, }, - }); + }; } async createSearchAnalyticsIndex() { From 9e032554a5a82f86a768793af7ec568d233c1762 Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 22:42:28 +0100 Subject: [PATCH 13/14] feat: refactor request ID generation in LoggingInterceptor to use utility function --- src/common/interceptors/logging.interceptor.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/common/interceptors/logging.interceptor.ts b/src/common/interceptors/logging.interceptor.ts index ed8341f8..33848feb 100644 --- a/src/common/interceptors/logging.interceptor.ts +++ b/src/common/interceptors/logging.interceptor.ts @@ -2,7 +2,11 @@ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } fr import { Observable, throwError } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { Request, Response } from 'express'; -import { CORRELATION_ID_HEADER, getCorrelationId } from '../utils/correlation.utils'; +import { + CORRELATION_ID_HEADER, + generateCorrelationId, + getCorrelationId, +} from '../utils/correlation.utils'; export interface RequestLog { requestId: string; @@ -50,7 +54,7 @@ export class LoggingInterceptor implements NestInterceptor { } const startTime = Date.now(); - const requestId = getCorrelationId() || this.generateRequestId(); + const requestId = getCorrelationId() || generateCorrelationId(); const response = httpCtx.getResponse(); response?.setHeader(CORRELATION_ID_HEADER, requestId); @@ -122,10 +126,6 @@ export class LoggingInterceptor implements NestInterceptor { } } - private generateRequestId(): string { - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - } - private resolveClientIp(request: Request): string { const forwarded = request.headers['x-forwarded-for']; if (typeof forwarded === 'string') { From 278342a23f9da48fda6fcf4d5889696f203bf790 Mon Sep 17 00:00:00 2001 From: Power70 Date: Thu, 23 Apr 2026 22:43:24 +0100 Subject: [PATCH 14/14] feat: add unit tests for SearchController and SearchService with input validation --- src/search/search.controller.spec.ts | 56 ++++++++++++ src/search/search.service.spec.ts | 128 +++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/search/search.controller.spec.ts create mode 100644 src/search/search.service.spec.ts 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.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'); + }); +});