diff --git a/BackendAcademy/src/courses/course.controller.ts b/BackendAcademy/src/courses/course.controller.ts index 8184dde18..063729905 100644 --- a/BackendAcademy/src/courses/course.controller.ts +++ b/BackendAcademy/src/courses/course.controller.ts @@ -56,7 +56,7 @@ export class CourseController { async update( @Param('id') id: string, @Body() dto: UpdateCourseDto, - ): Promise { + ): Promise { return this.courseService.update(id, dto); } @@ -113,7 +113,7 @@ export class CourseController { @Param('id') id: string, @Param('version') version: string, @Body() dto: RestoreRevisionDto, - ): Promise { + ): Promise { return this.courseService.restoreRevision( id, Number(version), diff --git a/BackendAcademy/src/health/health.service.spec.ts b/BackendAcademy/src/health/health.service.spec.ts new file mode 100644 index 000000000..22e0ec5ab --- /dev/null +++ b/BackendAcademy/src/health/health.service.spec.ts @@ -0,0 +1,223 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { HealthService } from './health.service'; +import { RedisService } from '../redis/redis.service'; +import { DatabaseService } from '../database/database.service'; +import { MonitoringService } from '../monitoring/monitoring.service'; + +describe('HealthService', () => { + let service: HealthService; + let redisService: RedisService; + let databaseService: DatabaseService; + let monitoringService: MonitoringService; + + const mockConfigService = { + get: jest.fn((key: string, defaultValue?: unknown) => { + if (key === 'READINESS_PROBE_TIMEOUT_MS') return 1_000; + return defaultValue; + }), + }; + + beforeEach(async () => { + redisService = { + get: jest.fn(), + } as unknown as RedisService; + + databaseService = { + isHealthy: jest.fn(), + } as unknown as DatabaseService; + + monitoringService = {} as MonitoringService; + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + HealthService, + { provide: ConfigService, useValue: mockConfigService }, + { provide: RedisService, useValue: redisService }, + { provide: DatabaseService, useValue: databaseService }, + { provide: MonitoringService, useValue: monitoringService }, + ], + }).compile(); + + service = moduleRef.get(HealthService); + }); + + describe('checkLiveness', () => { + it('returns alive: true with a timestamp', () => { + const result = service.checkLiveness(); + expect(result.alive).toBe(true); + expect(result.timestamp).toBeDefined(); + expect(new Date(result.timestamp).getTime()).not.toBeNaN(); + }); + }); + + describe('check (full health)', () => { + it('returns ok when all dependencies are healthy', async () => { + (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); + (redisService.get as jest.Mock).mockResolvedValue(null); + + const result = await service.check(); + expect(result.status).toBe('ok'); + expect(result.dependencies).toHaveLength(2); + expect(result.dependencies.every((d) => d.status === 'healthy')).toBe(true); + }); + + it('returns unavailable when Redis is unhealthy', async () => { + (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); + (redisService.get as jest.Mock).mockRejectedValue( + new Error('ECONNREFUSED'), + ); + + const result = await service.check(); + expect(result.status).toBe('unavailable'); + expect(result.dependencies.find((d) => d.name === 'redis')).toMatchObject({ + status: 'unhealthy', + }); + expect(result.dependencies.find((d) => d.name === 'database')).toMatchObject({ + status: 'healthy', + }); + }); + + it('returns degraded when database service is not injected', async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + HealthService, + { provide: ConfigService, useValue: mockConfigService }, + { provide: RedisService, useValue: redisService }, + // DatabaseService intentionally omitted (undefined) + ], + }).compile(); + + const svc = moduleRef.get(HealthService); + (redisService.get as jest.Mock).mockResolvedValue(null); + + const result = await svc.check(); + expect(result.status).toBe('degraded'); + const dbCheck = result.dependencies.find((d) => d.name === 'database'); + expect(dbCheck).toMatchObject({ + status: 'degraded', + error: 'DatabaseService not available (not injected)', + }); + }); + + it('returns unavailable when both Redis and database are unhealthy', async () => { + (databaseService.isHealthy as jest.Mock).mockRejectedValue( + new Error('FATAL'), + ); + (redisService.get as jest.Mock).mockRejectedValue( + new Error('Connection lost'), + ); + + const result = await service.check(); + expect(result.status).toBe('unavailable'); + }); + }); + + describe('checkReadiness', () => { + it('returns ready when all dependencies are healthy and workers are active', async () => { + (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); + (redisService.get as jest.Mock).mockResolvedValue(null); + + const result = await service.checkReadiness({ + ready: true, + queueDepth: 5, + activeWorkers: 2, + lastHeartbeat: new Date(), + }); + + expect(result.ready).toBe(true); + expect(result.checks).toHaveLength(4); + expect(result.checks.find((c) => c.name === 'workers')).toMatchObject({ + ready: true, + reason: expect.stringContaining('Workers active'), + }); + }); + + it('returns not ready when workers are stalled', async () => { + (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); + (redisService.get as jest.Mock).mockResolvedValue(null); + + const result = await service.checkReadiness({ + ready: false, + queueDepth: 100, + activeWorkers: 0, + lastHeartbeat: new Date(Date.now() - 120_000), + }); + + expect(result.ready).toBe(false); + const workerCheck = result.checks.find((c) => c.name === 'workers'); + expect(workerCheck).toMatchObject({ ready: false }); + }); + + it('returns not ready when database is unhealthy', async () => { + (databaseService.isHealthy as jest.Mock).mockRejectedValue( + new Error('Connection refused to 10.0.0.5:5432'), + ); + (redisService.get as jest.Mock).mockResolvedValue(null); + + const result = await service.checkReadiness(); + + expect(result.ready).toBe(false); + const dbCheck = result.checks.find((c) => c.name === 'database'); + expect(dbCheck?.ready).toBe(false); + // Reason should be sanitized — no IP addresses leaked + expect(dbCheck?.reason).not.toContain('10.0.0.5'); + expect(dbCheck?.reason).not.toContain('5432'); + }); + + it('returns not ready when Redis is unhealthy', async () => { + (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); + (redisService.get as jest.Mock).mockRejectedValue( + new Error('redis://127.0.0.1:6379 connection refused'), + ); + + const result = await service.checkReadiness(); + + expect(result.ready).toBe(false); + const redisCheck = result.checks.find((c) => c.name === 'redis'); + expect(redisCheck?.ready).toBe(false); + // Connection string should be sanitized + expect(redisCheck?.reason).not.toContain('redis://'); + expect(redisCheck?.reason).not.toContain('127.0.0.1'); + }); + + it('returns ready with infrastructure-only when no workerReadiness provided', async () => { + (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); + (redisService.get as jest.Mock).mockResolvedValue(null); + + const result = await service.checkReadiness(); + + expect(result.ready).toBe(true); + expect(result.checks.find((c) => c.name === 'workers')).toBeUndefined(); + }); + + it('sanitizes timeout reasons when probes hang', async () => { + // Simulate slow database by never resolving the readiness check. + // Use a fake ConfigService with a very short timeout for this test. + const fastConfig = { get: jest.fn(() => 50) } as unknown as ConfigService; + const slowDb = { + isHealthy: () => new Promise(() => {}), // never resolves + } as unknown as DatabaseService; + const healthyRedis = { + get: jest.fn().mockResolvedValue(null), + } as unknown as RedisService; + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + HealthService, + { provide: ConfigService, useValue: fastConfig }, + { provide: RedisService, useValue: healthyRedis }, + { provide: DatabaseService, useValue: slowDb }, + ], + }).compile(); + + const svc = moduleRef.get(HealthService); + const result = await svc.checkReadiness(); + + expect(result.ready).toBe(false); + const dbCheck = result.checks.find((c) => c.name === 'database'); + expect(dbCheck?.ready).toBe(false); + expect(dbCheck?.reason).toContain('timed out'); + }); + }); +}); diff --git a/BackendAcademy/src/health/health.service.ts b/BackendAcademy/src/health/health.service.ts index 411ab26b9..e25d212a6 100644 --- a/BackendAcademy/src/health/health.service.ts +++ b/BackendAcademy/src/health/health.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, Inject, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { RedisService } from '../redis/redis.service'; import { DatabaseService } from '../database/database.service'; +import { MonitoringService, MetricServiceNames } from '../monitoring/monitoring.service'; /** * Individual dependency health status. @@ -56,6 +57,7 @@ export class HealthService { private readonly configService: ConfigService, @Optional() @Inject(RedisService) private readonly redisService?: RedisService, @Optional() @Inject(DatabaseService) private readonly databaseService?: DatabaseService, + @Optional() @Inject(MonitoringService) private readonly monitoringService?: MonitoringService, ) { this.readinessTimeoutMs = this.configService.get( 'READINESS_PROBE_TIMEOUT_MS', @@ -102,6 +104,123 @@ export class HealthService { }; } + /** + * Extended readiness check that probes database, Redis, job queues, + * and external provider availability — #376. + * + * Each dependency is wrapped in a timeout so a stuck probe cannot + * block the readiness endpoint indefinitely. Error reasons are + * sanitized to avoid leaking sensitive connection details. + */ + async checkReadiness(workerReadiness?: WorkerReadiness): Promise { + const checks: ReadinessResult['checks'] = []; + + // Check dependency health first — if infra is down we are NOT ready. + // Wrap with timeout so a stuck dependency probe cannot block the entire readiness endpoint. + let fullHealth: HealthCheckResult; + try { + fullHealth = await withTimeout(this.check(), this.readinessTimeoutMs, 'health-check'); + } catch { + fullHealth = { + status: 'unavailable', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + dependencies: [], + }; + } + const infraReady = fullHealth.status === 'ok' || fullHealth.status === 'degraded'; + checks.push({ + name: 'infrastructure', + ready: infraReady, + reason: infraReady + ? 'All dependencies healthy or degraded' + : 'One or more dependencies unavailable', + }); + + // Check worker readiness if provided by the jobs service. + if (workerReadiness) { + checks.push({ + name: 'workers', + ready: workerReadiness.ready, + reason: workerReadiness.ready + ? `Workers active (${workerReadiness.activeWorkers}), queue depth: ${workerReadiness.queueDepth}` + : workerReadiness.lastHeartbeat + ? `Workers stalled — last heartbeat at ${workerReadiness.lastHeartbeat.toISOString()}` + : 'No workers active', + }); + } + + // Check individual database readiness with timeout + const dbCheck = await this.checkDatabaseReadiness(); + checks.push(dbCheck); + + // Check individual Redis readiness with timeout + const redisCheck = await this.checkRedisReadiness(); + checks.push(redisCheck); + + const allReady = checks.every((c) => c.ready); + + return { + ready: allReady, + timestamp: new Date().toISOString(), + checks, + }; + } + + /** + * Database readiness probe with timeout. Returns a sanitized error + * reason on failure so sensitive connection details are never leaked. + */ + private async checkDatabaseReadiness(): Promise { + try { + const result = await withTimeout( + this.checkDatabase(), + this.readinessTimeoutMs, + 'database', + ); + return { + name: 'database', + ready: result.status === 'healthy', + reason: result.status === 'healthy' + ? `Database responsive in ${result.latencyMs}ms` + : sanitizeReason(result.error ?? 'Database unhealthy'), + }; + } catch (err) { + return { + name: 'database', + ready: false, + reason: sanitizeReason((err as Error).message), + }; + } + } + + /** + * Redis readiness probe with timeout. Returns a sanitized error + * reason on failure so sensitive connection details are never leaked. + */ + private async checkRedisReadiness(): Promise { + try { + const result = await withTimeout( + this.checkRedis(), + this.readinessTimeoutMs, + 'redis', + ); + return { + name: 'redis', + ready: result.status === 'healthy', + reason: result.status === 'healthy' + ? `Redis responsive in ${result.latencyMs}ms` + : sanitizeReason(result.error ?? 'Redis unhealthy'), + }; + } catch (err) { + return { + name: 'redis', + ready: false, + reason: sanitizeReason((err as Error).message), + }; + } + } + /** * Probes Redis connectivity and latency. */ @@ -186,55 +305,6 @@ export class HealthService { } } - // ────────────────────────────────────────────────────────────────── - // #376: Readiness probes for background workers and queues - // ────────────────────────────────────────────────────────────────── - - /** - * Evaluates whether the application is ready to accept traffic, - * including background worker readiness and queue health. - * - * This is designed to be consumed by Kubernetes readiness probes or - * load-balancer health checks so that traffic is only routed to pods - * whose workers are fully initialized and whose queues are not - * dangerously backed up. - */ - async checkReadiness(workerReadiness?: WorkerReadiness): Promise { - const checks: ReadinessResult['checks'] = []; - - // Check dependency health first — if infra is down we are NOT ready. - const fullHealth = await this.check(); - const infraReady = fullHealth.status === 'ok' || fullHealth.status === 'degraded'; - checks.push({ - name: 'infrastructure', - ready: infraReady, - reason: infraReady - ? 'All dependencies healthy or degraded' - : 'One or more dependencies unavailable', - }); - - // Check worker readiness if provided by the jobs service. - if (workerReadiness) { - checks.push({ - name: 'workers', - ready: workerReadiness.ready, - reason: workerReadiness.ready - ? `Workers active (${workerReadiness.activeWorkers}), queue depth: ${workerReadiness.queueDepth}` - : workerReadiness.lastHeartbeat - ? `Workers stalled — last heartbeat at ${workerReadiness.lastHeartbeat.toISOString()}` - : 'No workers active', - }); - } - - const allReady = checks.every((c) => c.ready); - - return { - ready: allReady, - timestamp: new Date().toISOString(), - checks, - }; - } - /** * Lightweight liveness probe — simply confirms the process is alive. */ @@ -245,3 +315,44 @@ export class HealthService { }; } } + +// ────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────── + +/** + * Races a promise against a timeout. Resolves the promise or rejects + * with a descriptive timeout error after `ms` milliseconds. + */ +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`${label} readiness check timed out after ${ms}ms`)); + }, ms); + + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +/** + * Sanitize an error message so that sensitive connection details + * (host, port, credentials) are never exposed in readiness probes. + */ +function sanitizeReason(raw: string): string { + // Strip anything that looks like a connection string or host:port pair + return raw + .replace(/mongodb:\/\/[^\s]*/gi, '[connection]') + .replace(/postgres:\/\/[^\s]*/gi, '[connection]') + .replace(/redis:\/\/[^\s]*/gi, '[connection]') + .replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, '[host]') + .replace(/:\d{2,5}\b/g, ':[port]') + .slice(0, 200); +} diff --git a/BackendAcademy/src/monitoring/monitoring.metrics.ts b/BackendAcademy/src/monitoring/monitoring.metrics.ts index c35144d9e..55ac32620 100644 --- a/BackendAcademy/src/monitoring/monitoring.metrics.ts +++ b/BackendAcademy/src/monitoring/monitoring.metrics.ts @@ -1,8 +1,14 @@ -import { makeCounterProvider } from '@willsoto/nestjs-prometheus'; +import { makeCounterProvider, makeHistogramProvider, makeGaugeProvider } from '@willsoto/nestjs-prometheus'; + +// ────────────────────────────────────────────────────────────────── +// Counter metrics +// ────────────────────────────────────────────────────────────────── export const HTTP_REQUESTS_METRIC = 'app_http_requests_total'; export const DOMAIN_EVENTS_METRIC = 'app_domain_events_total'; export const ERROR_EVENTS_METRIC = 'app_error_events_total'; +export const SERVICE_ERRORS_METRIC = 'app_service_errors_total'; +export const DOMAIN_OUTCOMES_METRIC = 'app_domain_outcomes_total'; export const httpRequestsCounterProvider = makeCounterProvider({ name: HTTP_REQUESTS_METRIC, @@ -22,3 +28,105 @@ export const errorEventsCounterProvider = makeCounterProvider({ labelNames: ['source', 'reason', 'tenant_id', 'request_id'], }); +/** + * Per-service error counter. Tracks error rates broken down by + * service name (database, redis, ai, grading, payment, notification) + * and error type (timeout, connection, validation, etc.). + */ +export const serviceErrorsCounterProvider = makeCounterProvider({ + name: SERVICE_ERRORS_METRIC, + help: 'Total number of errors per service', + labelNames: ['service', 'error_type'], +}); + +/** + * Domain outcome counter. Tracks business-relevant outcomes such as + * course completions, payment successes/failures, and notification + * delivery results. + */ +export const domainOutcomesCounterProvider = makeCounterProvider({ + name: DOMAIN_OUTCOMES_METRIC, + help: 'Total number of domain outcome events', + labelNames: ['outcome', 'service', 'status'], +}); + +// ────────────────────────────────────────────────────────────────── +// Histogram metrics (latency) +// ────────────────────────────────────────────────────────────────── + +export const HTTP_LATENCY_METRIC = 'app_http_request_duration_seconds'; +export const DATABASE_LATENCY_METRIC = 'app_database_query_duration_seconds'; +export const REDIS_LATENCY_METRIC = 'app_redis_operation_duration_seconds'; +export const AI_LATENCY_METRIC = 'app_ai_provider_duration_seconds'; +export const GRADING_LATENCY_METRIC = 'app_grading_operation_duration_seconds'; +export const PAYMENT_LATENCY_METRIC = 'app_payment_operation_duration_seconds'; +export const NOTIFICATION_LATENCY_METRIC = 'app_notification_operation_duration_seconds'; + +export const httpLatencyHistogramProvider = makeHistogramProvider({ + name: HTTP_LATENCY_METRIC, + help: 'HTTP request latency in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], +}); + +export const databaseLatencyHistogramProvider = makeHistogramProvider({ + name: DATABASE_LATENCY_METRIC, + help: 'Database query latency in seconds', + labelNames: ['operation', 'table'], + buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1], +}); + +export const redisLatencyHistogramProvider = makeHistogramProvider({ + name: REDIS_LATENCY_METRIC, + help: 'Redis operation latency in seconds', + labelNames: ['operation'], + buckets: [0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1], +}); + +export const aiLatencyHistogramProvider = makeHistogramProvider({ + name: AI_LATENCY_METRIC, + help: 'AI provider request latency in seconds', + labelNames: ['provider', 'operation'], + buckets: [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30], +}); + +export const gradingLatencyHistogramProvider = makeHistogramProvider({ + name: GRADING_LATENCY_METRIC, + help: 'Grading operation latency in seconds', + labelNames: ['operation'], + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], +}); + +export const paymentLatencyHistogramProvider = makeHistogramProvider({ + name: PAYMENT_LATENCY_METRIC, + help: 'Payment operation latency in seconds', + labelNames: ['operation', 'provider'], + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], +}); + +export const notificationLatencyHistogramProvider = makeHistogramProvider({ + name: NOTIFICATION_LATENCY_METRIC, + help: 'Notification delivery latency in seconds', + labelNames: ['channel', 'operation'], + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], +}); + +// ────────────────────────────────────────────────────────────────── +// Gauge metrics (queue depth / saturation) +// ────────────────────────────────────────────────────────────────── + +export const JOBS_QUEUE_DEPTH_METRIC = 'app_jobs_queue_depth'; +export const DEAD_LETTER_QUEUE_DEPTH_METRIC = 'app_dead_letter_queue_depth'; + +export const jobsQueueDepthGaugeProvider = makeGaugeProvider({ + name: JOBS_QUEUE_DEPTH_METRIC, + help: 'Current depth of the jobs queue (pending + retrying)', + labelNames: ['queue'], +}); + +export const deadLetterQueueDepthGaugeProvider = makeGaugeProvider({ + name: DEAD_LETTER_QUEUE_DEPTH_METRIC, + help: 'Current depth of the dead-letter queue (exhausted retries)', + labelNames: ['queue'], +}); + diff --git a/BackendAcademy/src/monitoring/monitoring.module.ts b/BackendAcademy/src/monitoring/monitoring.module.ts index 9039e1476..4c52e1e05 100644 --- a/BackendAcademy/src/monitoring/monitoring.module.ts +++ b/BackendAcademy/src/monitoring/monitoring.module.ts @@ -1,8 +1,44 @@ import { Module } from '@nestjs/common'; +import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { MetricsService } from './metrics.service'; +import { + httpRequestsCounterProvider, + domainEventsCounterProvider, + errorEventsCounterProvider, + serviceErrorsCounterProvider, + domainOutcomesCounterProvider, + httpLatencyHistogramProvider, + databaseLatencyHistogramProvider, + redisLatencyHistogramProvider, + aiLatencyHistogramProvider, + gradingLatencyHistogramProvider, + paymentLatencyHistogramProvider, + notificationLatencyHistogramProvider, + jobsQueueDepthGaugeProvider, + deadLetterQueueDepthGaugeProvider, +} from './monitoring.metrics'; @Module({ - providers: [MetricsService], + imports: [ + PrometheusModule.register({ defaultMetrics: { enabled: false } }), + ], + providers: [ + MetricsService, + httpRequestsCounterProvider, + domainEventsCounterProvider, + errorEventsCounterProvider, + serviceErrorsCounterProvider, + domainOutcomesCounterProvider, + httpLatencyHistogramProvider, + databaseLatencyHistogramProvider, + redisLatencyHistogramProvider, + aiLatencyHistogramProvider, + gradingLatencyHistogramProvider, + paymentLatencyHistogramProvider, + notificationLatencyHistogramProvider, + jobsQueueDepthGaugeProvider, + deadLetterQueueDepthGaugeProvider, + ], exports: [MetricsService], }) export class MonitoringModule {} diff --git a/BackendAcademy/src/monitoring/monitoring.service.spec.ts b/BackendAcademy/src/monitoring/monitoring.service.spec.ts index 1da8137d5..1a0a42170 100644 --- a/BackendAcademy/src/monitoring/monitoring.service.spec.ts +++ b/BackendAcademy/src/monitoring/monitoring.service.spec.ts @@ -1,10 +1,21 @@ import { Test, TestingModule } from '@nestjs/testing'; -import type { Counter } from 'prom-client'; +import type { Counter, Gauge, Histogram } from 'prom-client'; import { getToken } from '@willsoto/nestjs-prometheus'; import { DOMAIN_EVENTS_METRIC, ERROR_EVENTS_METRIC, HTTP_REQUESTS_METRIC, + SERVICE_ERRORS_METRIC, + DOMAIN_OUTCOMES_METRIC, + HTTP_LATENCY_METRIC, + DATABASE_LATENCY_METRIC, + REDIS_LATENCY_METRIC, + AI_LATENCY_METRIC, + GRADING_LATENCY_METRIC, + PAYMENT_LATENCY_METRIC, + NOTIFICATION_LATENCY_METRIC, + JOBS_QUEUE_DEPTH_METRIC, + DEAD_LETTER_QUEUE_DEPTH_METRIC, } from './monitoring.metrics'; import { MonitoringService } from './monitoring.service'; @@ -16,17 +27,50 @@ import { MonitoringService } from './monitoring.service'; const HTTP_REQUESTS_TOKEN = getToken(HTTP_REQUESTS_METRIC); const DOMAIN_EVENTS_TOKEN = getToken(DOMAIN_EVENTS_METRIC); const ERROR_EVENTS_TOKEN = getToken(ERROR_EVENTS_METRIC); +const SERVICE_ERRORS_TOKEN = getToken(SERVICE_ERRORS_METRIC); +const DOMAIN_OUTCOMES_TOKEN = getToken(DOMAIN_OUTCOMES_METRIC); +const HTTP_LATENCY_TOKEN = getToken(HTTP_LATENCY_METRIC); +const DATABASE_LATENCY_TOKEN = getToken(DATABASE_LATENCY_METRIC); +const REDIS_LATENCY_TOKEN = getToken(REDIS_LATENCY_METRIC); +const AI_LATENCY_TOKEN = getToken(AI_LATENCY_METRIC); +const GRADING_LATENCY_TOKEN = getToken(GRADING_LATENCY_METRIC); +const PAYMENT_LATENCY_TOKEN = getToken(PAYMENT_LATENCY_METRIC); +const NOTIFICATION_LATENCY_TOKEN = getToken(NOTIFICATION_LATENCY_METRIC); +const JOBS_QUEUE_DEPTH_TOKEN = getToken(JOBS_QUEUE_DEPTH_METRIC); +const DEAD_LETTER_QUEUE_DEPTH_TOKEN = getToken(DEAD_LETTER_QUEUE_DEPTH_METRIC); describe('MonitoringService', () => { let service: MonitoringService; let httpRequests: Counter; let domainEvents: Counter; let errorEvents: Counter; + let serviceErrors: Counter; + let domainOutcomes: Counter; + let httpLatency: Histogram; + let databaseLatency: Histogram; + let redisLatency: Histogram; + let aiLatency: Histogram; + let gradingLatency: Histogram; + let paymentLatency: Histogram; + let notificationLatency: Histogram; + let jobsQueueDepth: Gauge; + let deadLetterQueueDepth: Gauge; beforeEach(async () => { const httpRequestsMock = { inc: jest.fn() } as unknown as Counter; const domainEventsMock = { inc: jest.fn() } as unknown as Counter; const errorEventsMock = { inc: jest.fn() } as unknown as Counter; + const serviceErrorsMock = { inc: jest.fn() } as unknown as Counter; + const domainOutcomesMock = { inc: jest.fn() } as unknown as Counter; + const httpLatencyMock = { observe: jest.fn() } as unknown as Histogram; + const databaseLatencyMock = { observe: jest.fn() } as unknown as Histogram; + const redisLatencyMock = { observe: jest.fn() } as unknown as Histogram; + const aiLatencyMock = { observe: jest.fn() } as unknown as Histogram; + const gradingLatencyMock = { observe: jest.fn() } as unknown as Histogram; + const paymentLatencyMock = { observe: jest.fn() } as unknown as Histogram; + const notificationLatencyMock = { observe: jest.fn() } as unknown as Histogram; + const jobsQueueDepthMock = { set: jest.fn() } as unknown as Gauge; + const deadLetterQueueDepthMock = { set: jest.fn() } as unknown as Gauge; const moduleRef: TestingModule = await Test.createTestingModule({ providers: [ @@ -34,6 +78,17 @@ describe('MonitoringService', () => { { provide: HTTP_REQUESTS_TOKEN, useValue: httpRequestsMock }, { provide: DOMAIN_EVENTS_TOKEN, useValue: domainEventsMock }, { provide: ERROR_EVENTS_TOKEN, useValue: errorEventsMock }, + { provide: SERVICE_ERRORS_TOKEN, useValue: serviceErrorsMock }, + { provide: DOMAIN_OUTCOMES_TOKEN, useValue: domainOutcomesMock }, + { provide: HTTP_LATENCY_TOKEN, useValue: httpLatencyMock }, + { provide: DATABASE_LATENCY_TOKEN, useValue: databaseLatencyMock }, + { provide: REDIS_LATENCY_TOKEN, useValue: redisLatencyMock }, + { provide: AI_LATENCY_TOKEN, useValue: aiLatencyMock }, + { provide: GRADING_LATENCY_TOKEN, useValue: gradingLatencyMock }, + { provide: PAYMENT_LATENCY_TOKEN, useValue: paymentLatencyMock }, + { provide: NOTIFICATION_LATENCY_TOKEN, useValue: notificationLatencyMock }, + { provide: JOBS_QUEUE_DEPTH_TOKEN, useValue: jobsQueueDepthMock }, + { provide: DEAD_LETTER_QUEUE_DEPTH_TOKEN, useValue: deadLetterQueueDepthMock }, ], }).compile(); @@ -41,6 +96,17 @@ describe('MonitoringService', () => { httpRequests = httpRequestsMock; domainEvents = domainEventsMock; errorEvents = errorEventsMock; + serviceErrors = serviceErrorsMock; + domainOutcomes = domainOutcomesMock; + httpLatency = httpLatencyMock; + databaseLatency = databaseLatencyMock; + redisLatency = redisLatencyMock; + aiLatency = aiLatencyMock; + gradingLatency = gradingLatencyMock; + paymentLatency = paymentLatencyMock; + notificationLatency = notificationLatencyMock; + jobsQueueDepth = jobsQueueDepthMock; + deadLetterQueueDepth = deadLetterQueueDepthMock; }); describe('recordHttpRequest', () => { @@ -51,6 +117,8 @@ describe('MonitoringService', () => { method: 'GET', route: '/health', status_code: '200', + tenant_id: 'unknown', + request_id: 'unknown', }); }); @@ -60,6 +128,8 @@ describe('MonitoringService', () => { method: 'POST', route: '/social/posts', status_code: '201', + tenant_id: 'unknown', + request_id: 'unknown', }); }); @@ -69,6 +139,8 @@ describe('MonitoringService', () => { method: 'GET', route: '/', status_code: '204', + tenant_id: 'unknown', + request_id: 'unknown', }); }); }); @@ -79,6 +151,8 @@ describe('MonitoringService', () => { expect(domainEvents.inc).toHaveBeenCalledWith({ event_type: 'badge_awarded', source: 'badges', + tenant_id: 'unknown', + request_id: 'unknown', }); }); }); @@ -89,6 +163,127 @@ describe('MonitoringService', () => { expect(errorEvents.inc).toHaveBeenCalledWith({ source: 'submissions', reason: 'grading_failed', + tenant_id: 'unknown', + request_id: 'unknown', + }); + }); + }); + + describe('recordServiceError', () => { + it('increments the service errors counter with service and error_type', () => { + service.recordServiceError('database', 'timeout'); + expect(serviceErrors.inc).toHaveBeenCalledWith({ + service: 'database', + error_type: 'timeout', + }); + }); + + it('works for all service types', () => { + service.recordServiceError('redis', 'connection'); + expect(serviceErrors.inc).toHaveBeenCalledWith({ + service: 'redis', + error_type: 'connection', + }); + }); + }); + + describe('recordHttpLatency', () => { + it('observes latency in seconds (ms / 1000)', () => { + service.recordHttpLatency('GET', '/health', 200, 150); + expect(httpLatency.observe).toHaveBeenCalledWith( + { method: 'GET', route: '/health', status_code: '200' }, + 0.15, + ); + }); + }); + + describe('recordDatabaseLatency', () => { + it('observes latency with operation and table labels', () => { + service.recordDatabaseLatency('find', 'courses', 25); + expect(databaseLatency.observe).toHaveBeenCalledWith( + { operation: 'find', table: 'courses' }, + 0.025, + ); + }); + }); + + describe('recordRedisLatency', () => { + it('observes latency with operation label', () => { + service.recordRedisLatency('get', 5); + expect(redisLatency.observe).toHaveBeenCalledWith({ operation: 'get' }, 0.005); + }); + }); + + describe('recordAiLatency', () => { + it('observes latency with provider and operation labels', () => { + service.recordAiLatency('openai', 'chat', 2500); + expect(aiLatency.observe).toHaveBeenCalledWith( + { provider: 'openai', operation: 'chat' }, + 2.5, + ); + }); + }); + + describe('recordGradingLatency', () => { + it('observes latency with operation label', () => { + service.recordGradingLatency('grade_submission', 500); + expect(gradingLatency.observe).toHaveBeenCalledWith( + { operation: 'grade_submission' }, + 0.5, + ); + }); + }); + + describe('recordPaymentLatency', () => { + it('observes latency with operation and provider labels', () => { + service.recordPaymentLatency('create', 'stellar', 100); + expect(paymentLatency.observe).toHaveBeenCalledWith( + { operation: 'create', provider: 'stellar' }, + 0.1, + ); + }); + }); + + describe('recordNotificationLatency', () => { + it('observes latency with channel and operation labels', () => { + service.recordNotificationLatency('email', 'send', 200); + expect(notificationLatency.observe).toHaveBeenCalledWith( + { channel: 'email', operation: 'send' }, + 0.2, + ); + }); + }); + + describe('setJobsQueueDepth', () => { + it('sets the gauge with queue and depth values', () => { + service.setJobsQueueDepth('webhooks', 42); + expect(jobsQueueDepth.set).toHaveBeenCalledWith({ queue: 'webhooks' }, 42); + }); + }); + + describe('setDeadLetterQueueDepth', () => { + it('sets the gauge with queue and depth values', () => { + service.setDeadLetterQueueDepth('webhooks', 3); + expect(deadLetterQueueDepth.set).toHaveBeenCalledWith({ queue: 'webhooks' }, 3); + }); + }); + + describe('recordDomainOutcome', () => { + it('increments with outcome, service, and status labels', () => { + service.recordDomainOutcome('course_completed', 'courses', 'success'); + expect(domainOutcomes.inc).toHaveBeenCalledWith({ + outcome: 'course_completed', + service: 'courses', + status: 'success', + }); + }); + + it('tracks payment failure outcomes', () => { + service.recordDomainOutcome('payment_processed', 'payment', 'failure'); + expect(domainOutcomes.inc).toHaveBeenCalledWith({ + outcome: 'payment_processed', + service: 'payment', + status: 'failure', }); }); }); diff --git a/BackendAcademy/src/monitoring/monitoring.service.ts b/BackendAcademy/src/monitoring/monitoring.service.ts index 75657d6f3..11e8b9635 100644 --- a/BackendAcademy/src/monitoring/monitoring.service.ts +++ b/BackendAcademy/src/monitoring/monitoring.service.ts @@ -1,13 +1,53 @@ -import { Injectable } from '@nestjs/common'; -import { Counter, Gauge } from 'prom-client'; +import { Injectable, Logger } from '@nestjs/common'; +import { Counter, Gauge, Histogram } from 'prom-client'; import { InjectMetric } from '@willsoto/nestjs-prometheus'; import { DOMAIN_EVENTS_METRIC, ERROR_EVENTS_METRIC, HTTP_REQUESTS_METRIC, + SERVICE_ERRORS_METRIC, + DOMAIN_OUTCOMES_METRIC, + HTTP_LATENCY_METRIC, + DATABASE_LATENCY_METRIC, + REDIS_LATENCY_METRIC, + AI_LATENCY_METRIC, + GRADING_LATENCY_METRIC, + PAYMENT_LATENCY_METRIC, + NOTIFICATION_LATENCY_METRIC, + JOBS_QUEUE_DEPTH_METRIC, + DEAD_LETTER_QUEUE_DEPTH_METRIC, } from './monitoring.metrics'; import { CorrelationLoggerService } from '../logging/logger.service'; +/** + * Service-level metric names used by {@link SERVICE_ERRORS_METRIC}. + * Keeping these as a const enum-like block avoids typos in label values. + */ +export const MetricServiceNames = { + DATABASE: 'database', + REDIS: 'redis', + AI: 'ai', + GRADING: 'grading', + PAYMENT: 'payment', + NOTIFICATION: 'notification', + HTTP: 'http', +} as const; + +export type MetricServiceName = (typeof MetricServiceNames)[keyof typeof MetricServiceNames]; + +/** + * Error type labels for {@link SERVICE_ERRORS_METRIC}. + */ +export const MetricErrorTypes = { + TIMEOUT: 'timeout', + CONNECTION: 'connection', + VALIDATION: 'validation', + RATE_LIMIT: 'rate_limit', + UNKNOWN: 'unknown', +} as const; + +export type MetricErrorType = (typeof MetricErrorTypes)[keyof typeof MetricErrorTypes]; + /** * Thin wrapper around the Prometheus counters registered by * {@link MonitoringModule}. Other modules inject this service to record @@ -25,6 +65,8 @@ export class MonitoringService { private apiKeyRevocations = 0; private apiKeyAnomalies = 0; + private readonly logger = new Logger(MonitoringService.name); + constructor( @InjectMetric(HTTP_REQUESTS_METRIC) private readonly httpRequests: Counter, @@ -32,6 +74,28 @@ export class MonitoringService { private readonly domainEvents: Counter, @InjectMetric(ERROR_EVENTS_METRIC) private readonly errorEvents: Counter, + @InjectMetric(SERVICE_ERRORS_METRIC) + private readonly serviceErrors: Counter, + @InjectMetric(DOMAIN_OUTCOMES_METRIC) + private readonly domainOutcomes: Counter, + @InjectMetric(HTTP_LATENCY_METRIC) + private readonly httpLatency: Histogram, + @InjectMetric(DATABASE_LATENCY_METRIC) + private readonly databaseLatency: Histogram, + @InjectMetric(REDIS_LATENCY_METRIC) + private readonly redisLatency: Histogram, + @InjectMetric(AI_LATENCY_METRIC) + private readonly aiLatency: Histogram, + @InjectMetric(GRADING_LATENCY_METRIC) + private readonly gradingLatency: Histogram, + @InjectMetric(PAYMENT_LATENCY_METRIC) + private readonly paymentLatency: Histogram, + @InjectMetric(NOTIFICATION_LATENCY_METRIC) + private readonly notificationLatency: Histogram, + @InjectMetric(JOBS_QUEUE_DEPTH_METRIC) + private readonly jobsQueueDepth: Gauge, + @InjectMetric(DEAD_LETTER_QUEUE_DEPTH_METRIC) + private readonly deadLetterQueueDepth: Gauge, ) {} /** @@ -51,14 +115,25 @@ export class MonitoringService { * `/` so that label cardinality stays bounded. */ recordHttpRequest(method: string, route: string, statusCode: number): void { + const normalizedRoute = normalizeRoute(route); this.httpRequests.inc({ method, - route: normalizeRoute(route), + route: normalizedRoute, status_code: statusCode.toString(), ...this.getRequestContext(), }); } + /** + * Record HTTP request latency in the histogram. + */ + recordHttpLatency(method: string, route: string, statusCode: number, durationMs: number): void { + this.httpLatency.observe( + { method, route: normalizeRoute(route), status_code: statusCode.toString() }, + durationMs / 1000, + ); + } + /** * Record a domain/business event (e.g. `badge_awarded` from the badges * module). The `source` label identifies the originating module. @@ -123,6 +198,95 @@ export class MonitoringService { }); } + // ────────────────────────────────────────────────────────────────── + // Service-level error tracking + // ────────────────────────────────────────────────────────────────── + + /** + * Record a service-level error (database, redis, ai, grading, payment, + * notification). The `errorType` label should be one of the + * {@link MetricErrorTypes} constants to keep cardinality bounded. + */ + recordServiceError(service: MetricServiceName, errorType: MetricErrorType): void { + this.serviceErrors.inc({ service, error_type: errorType }); + } + + // ────────────────────────────────────────────────────────────────── + // Latency histograms per subsystem + // ────────────────────────────────────────────────────────────────── + + /** + * Record database query latency. + */ + recordDatabaseLatency(operation: string, table: string, durationMs: number): void { + this.databaseLatency.observe({ operation, table }, durationMs / 1000); + } + + /** + * Record Redis operation latency. + */ + recordRedisLatency(operation: string, durationMs: number): void { + this.redisLatency.observe({ operation }, durationMs / 1000); + } + + /** + * Record AI provider request latency. + */ + recordAiLatency(provider: string, operation: string, durationMs: number): void { + this.aiLatency.observe({ provider, operation }, durationMs / 1000); + } + + /** + * Record grading operation latency. + */ + recordGradingLatency(operation: string, durationMs: number): void { + this.gradingLatency.observe({ operation }, durationMs / 1000); + } + + /** + * Record payment operation latency. + */ + recordPaymentLatency(operation: string, provider: string, durationMs: number): void { + this.paymentLatency.observe({ operation, provider }, durationMs / 1000); + } + + /** + * Record notification delivery latency. + */ + recordNotificationLatency(channel: string, operation: string, durationMs: number): void { + this.notificationLatency.observe({ channel, operation }, durationMs / 1000); + } + + // ────────────────────────────────────────────────────────────────── + // Queue depth saturation gauges + // ────────────────────────────────────────────────────────────────── + + /** + * Set the current jobs queue depth (pending + retrying). + */ + setJobsQueueDepth(queue: string, depth: number): void { + this.jobsQueueDepth.set({ queue }, depth); + } + + /** + * Set the current dead-letter queue depth (exhausted retries). + */ + setDeadLetterQueueDepth(queue: string, depth: number): void { + this.deadLetterQueueDepth.set({ queue }, depth); + } + + // ────────────────────────────────────────────────────────────────── + // Domain outcome tracking + // ────────────────────────────────────────────────────────────────── + + /** + * Record a domain outcome event (course completion, payment success, + * notification delivered, etc.). + */ + recordDomainOutcome(outcome: string, service: string, status: 'success' | 'failure' | 'skipped'): void { + this.domainOutcomes.inc({ outcome, service, status }); + } + /** * Get snapshot of internal counters for health / debug endpoints. */ diff --git a/BackendAcademy/src/search/in-memory-search.repository.ts b/BackendAcademy/src/search/in-memory-search.repository.ts new file mode 100644 index 000000000..f600932de --- /dev/null +++ b/BackendAcademy/src/search/in-memory-search.repository.ts @@ -0,0 +1,182 @@ +import { Injectable, Optional } from '@nestjs/common'; +import { SearchRepository } from './interfaces/search-repository.interface'; +import { UserSearchHit, PostSearchHit, SearchResults } from './interfaces/search.interface'; +import { UserProfileService } from '../users/user-profile.service'; +import { UsersService } from '../users/users.service'; +import { SocialService } from '../social/social.service'; + +/** + * In-memory search repository backed by the UserProfileService and + * SocialService. Applies authorization and visibility rules: + * + * - Users: only returns profiles that exist in UserProfileService. + * Deleted users (tracked by UsersService) are excluded. + * - Posts: only returns posts with `approved` moderation status. + * + * This replaces the hardcoded fixture arrays that previously lived in + * SearchService, so search results now reflect real durable records. + */ +@Injectable() +export class InMemorySearchRepository implements SearchRepository { + private static readonly MAX_LIMIT = 50; + private static readonly DEFAULT_LIMIT = 10; + + constructor( + @Optional() private readonly userProfileService?: UserProfileService, + @Optional() private readonly usersService?: UsersService, + @Optional() private readonly socialService?: SocialService, + ) {} + + searchUsers(query: { + q?: string; + limit?: number; + offset?: number; + }): SearchResults { + const profiles = this.getVisibleUsers(); + return this.paginate( + profiles, + query.q, + query.limit, + query.offset, + (u) => `${u.id} ${u.username} ${u.displayName}`, + ); + } + + searchPosts(query: { + q?: string; + limit?: number; + offset?: number; + }): SearchResults { + const posts = this.getVisiblePosts(); + return this.paginate( + posts, + query.q, + query.limit, + query.offset, + (p) => `${p.id} ${p.title} ${p.body}`, + ); + } + + // ────────────────────────────────────────────────────────────────── + // Private: data access with authorization/visibility filtering + // ────────────────────────────────────────────────────────────────── + + /** + * Fetches visible users from UserProfileService, excluding deleted accounts. + * Falls back to an empty array if the service is unavailable. + */ + private getVisibleUsers(): UserSearchHit[] { + if (!this.userProfileService) { + return []; + } + + // Synchronous snapshot — UserProfileService.findAll is sync-like (Map-backed) + // but the interface is async, so we access the internal store directly + // via the profiles Map. In production this would be a database query. + // + // Since UserProfileService.findAll returns a Promise, we read the + // profiles synchronously by accessing the service's internal data. + // This is acceptable for in-memory stores; a database-backed repository + // would use a proper query instead. + // + // To keep this testable and avoid tight coupling, we call findAll and + // handle the result. For the in-memory case, this resolves immediately. + let profiles: Awaited> = []; + + // We use a synchronous approach here by accessing the profiles via the + // service's public API. Since the UserProfileService stores in a Map, + // findAll() resolves immediately with a fresh array. + // + // Note: This is intentionally not awaited at the call site because + // the SearchService.searchUsers method is synchronous. The repository + // implementation must also be synchronous for the in-memory case. + // + // In production, a database-backed implementation would be async and + // the SearchService.searchUsers method would become async too. + try { + // Access profiles directly — the Map is the source of truth + const svc = this.userProfileService as UserProfileService & { + profiles: Map; + }; + profiles = Array.from(svc.profiles.values()); + } catch { + return []; + } + + return profiles + .filter((profile) => { + // Exclude deleted users + if (this.usersService?.isDeleted(profile.userId)) { + return false; + } + return true; + }) + .map((profile) => ({ + id: profile.userId, + username: profile.displayName.toLowerCase().replace(/\s+/g, '-'), + displayName: profile.displayName, + })); + } + + /** + * Fetches visible posts from SocialService, applying moderation + * visibility rules. Only `approved` posts are returned. + */ + private getVisiblePosts(): PostSearchHit[] { + if (!this.socialService) { + return []; + } + + // Get only approved posts (visibility rule) + const feedResult = this.socialService.getFeed({ + limit: 10_000, // large limit to get all posts + status: 'approved' as any, + }); + + return feedResult.posts.map((post) => ({ + id: post.id, + title: post.content.slice(0, 100), // Use first 100 chars as title + body: post.content, + })); + } + + // ────────────────────────────────────────────────────────────────── + // Pagination helper + // ────────────────────────────────────────────────────────────────── + + private paginate( + items: T[], + q: string | undefined, + limit: number | undefined, + offset: number | undefined, + matchFields: (item: T) => string, + ): SearchResults { + const rawLimit = Number(limit); + const effectiveLimit = + Number.isFinite(rawLimit) && rawLimit > 0 + ? Math.min(rawLimit, InMemorySearchRepository.MAX_LIMIT) + : InMemorySearchRepository.DEFAULT_LIMIT; + const effectiveOffset = Math.max(0, Number(offset) || 0); + const needle = (q || '').toLowerCase().trim(); + + const matched = needle + ? items.filter((item) => + matchFields(item).toLowerCase().includes(needle), + ) + : items; + + const total = matched.length; + const page = matched.slice(effectiveOffset, effectiveOffset + effectiveLimit); + const hasMore = effectiveOffset + page.length < total; + + const response: SearchResults = { + entries: page, + total, + hasMore, + }; + if (hasMore) { + response.nextOffset = effectiveOffset + page.length; + } + return response; + } +} diff --git a/BackendAcademy/src/search/index.ts b/BackendAcademy/src/search/index.ts index d3d475523..5155631a9 100644 --- a/BackendAcademy/src/search/index.ts +++ b/BackendAcademy/src/search/index.ts @@ -2,3 +2,6 @@ export { SearchModule } from './search.module'; export { SearchService } from './search.service'; export { SearchIndexerService } from './search-indexer.service'; export { SearchCoursesQueryDto } from './dto/search-courses-query.dto'; +export { InMemorySearchRepository } from './in-memory-search.repository'; +export { SEARCH_REPOSITORY } from './search.constants'; +export type { SearchRepository } from './interfaces/search-repository.interface'; diff --git a/BackendAcademy/src/search/interfaces/search-repository.interface.ts b/BackendAcademy/src/search/interfaces/search-repository.interface.ts new file mode 100644 index 000000000..0d0747a14 --- /dev/null +++ b/BackendAcademy/src/search/interfaces/search-repository.interface.ts @@ -0,0 +1,48 @@ +import { UserSearchHit, PostSearchHit } from './search.interface'; + +/** + * Repository interface for search data access. + * + * Replaces the hardcoded fixture arrays in SearchService with a proper + * data-access layer. Implementations pull from durable stores + * (UserProfileService, SocialService) and apply authorization and + * visibility rules before returning results. + */ +export interface SearchUserRepository { + /** + * Search users by substring match across id, username, and displayName. + * Returns only visible, non-deleted users. + */ + searchUsers(query: { + q?: string; + limit?: number; + offset?: number; + }): { + entries: UserSearchHit[]; + total: number; + hasMore: boolean; + nextOffset?: number; + }; +} + +export interface SearchPostRepository { + /** + * Search posts by substring match across id, title, and body. + * Returns only approved (visible) posts that pass authorization rules. + */ + searchPosts(query: { + q?: string; + limit?: number; + offset?: number; + }): { + entries: PostSearchHit[]; + total: number; + hasMore: boolean; + nextOffset?: number; + }; +} + +/** + * Combined search repository for all entity types. + */ +export type SearchRepository = SearchUserRepository & SearchPostRepository; diff --git a/BackendAcademy/src/search/search-indexer.service.spec.ts b/BackendAcademy/src/search/search-indexer.service.spec.ts new file mode 100644 index 000000000..671330e9f --- /dev/null +++ b/BackendAcademy/src/search/search-indexer.service.spec.ts @@ -0,0 +1,180 @@ +import { CourseEntity } from '../courses/course.entity'; +import { SearchIndexerService } from './search-indexer.service'; + +describe('SearchIndexerService', () => { + let indexer: SearchIndexerService; + + beforeEach(() => { + indexer = new SearchIndexerService(); + }); + + function makeCourse(overrides: Partial = {}): CourseEntity { + return new CourseEntity({ + id: overrides.id ?? crypto.randomUUID(), + title: overrides.title ?? 'Rust Basics', + description: overrides.description ?? 'Learn Rust fundamentals', + tags: overrides.tags ?? ['rust'], + category: overrides.category ?? 'fundamentals', + categories: overrides.categories ?? ['fundamentals'], + level: overrides.level as any, + order: overrides.order ?? 1, + learningPathId: overrides.learningPathId ?? 'rust', + duration: overrides.duration ?? 60, + ...overrides, + }); + } + + describe('indexCourse / removeCourse / size', () => { + it('indexes a course and reports correct size', () => { + const course = makeCourse({ id: 'c1' }); + indexer.indexCourse(course); + expect(indexer.size()).toBe(1); + }); + + it('idempotently upserts the same course', () => { + indexer.indexCourse(makeCourse({ id: 'c1', title: 'V1' })); + indexer.indexCourse(makeCourse({ id: 'c1', title: 'V2' })); + expect(indexer.size()).toBe(1); + const courses = indexer.getIndexedCourses(); + expect(courses[0].title).toBe('V2'); + }); + + it('removes a course', () => { + indexer.indexCourse(makeCourse({ id: 'c1' })); + indexer.removeCourse('c1'); + expect(indexer.size()).toBe(0); + }); + }); + + describe('scoreCourse', () => { + it('scores title matches higher than description', () => { + const course = makeCourse({ + title: 'Advanced Rust', + description: 'This course covers everything', + tags: [], + categories: [], + category: 'general', + }); + const titleScore = indexer.scoreCourse(course, 'rust'); + // title (3) + description (0) = 3 + expect(titleScore).toBe(3); + }); + + it('scores tag matches higher than description', () => { + const course = makeCourse({ + title: 'Web Frameworks', + description: 'Build apps', + tags: ['rust', 'axum'], + }); + // title doesn't match "rust", tags match (2), description doesn't match + const score = indexer.scoreCourse(course, 'rust'); + expect(score).toBe(2); + }); + + it('returns 0 when nothing matches', () => { + const course = makeCourse({ + title: 'Python Basics', + description: 'Learn Python', + tags: ['python'], + }); + expect(indexer.scoreCourse(course, 'rust')).toBe(0); + }); + + it('scores category matches', () => { + const course = makeCourse({ + category: 'systems', + categories: ['systems', 'performance'], + }); + expect(indexer.scoreCourse(course, 'systems')).toBe(0.5); + }); + }); + + describe('rankCourses — deterministic ordering', () => { + it('sorts by score descending', () => { + const highScore = makeCourse({ + id: 'c-high', + title: 'Rust Mastery', + tags: ['rust'], + }); + const lowScore = makeCourse({ + id: 'c-low', + title: 'Python Basics', + description: 'Rust is mentioned here', + }); + + const ranked = indexer.rankCourses([lowScore, highScore], 'rust'); + expect(ranked).toHaveLength(2); + expect(ranked[0].course.id).toBe('c-high'); + expect(ranked[1].course.id).toBe('c-low'); + }); + + it('breaks ties deterministically by course.id', () => { + // Two courses with identical scores + const courseA = makeCourse({ + id: 'aaa', + title: 'Course A', + description: 'rust content', + }); + const courseB = makeCourse({ + id: 'zzz', + title: 'Course B', + description: 'rust content', + }); + + const ranked1 = indexer.rankCourses([courseB, courseA], 'rust'); + const ranked2 = indexer.rankCourses([courseA, courseB], 'rust'); + + // Same order regardless of input order + expect(ranked1[0].course.id).toBe('aaa'); + expect(ranked1[1].course.id).toBe('zzz'); + expect(ranked2[0].course.id).toBe('aaa'); + expect(ranked2[1].course.id).toBe('zzz'); + }); + + it('filters out courses with score 0', () => { + const match = makeCourse({ id: 'm1', title: 'Rust Course' }); + const noMatch = makeCourse({ + id: 'n1', + title: 'Python Course', + description: 'Python programming', + tags: ['python'], + categories: ['python'], + category: 'python', + }); + + const ranked = indexer.rankCourses([noMatch, match], 'rust'); + expect(ranked).toHaveLength(1); + expect(ranked[0].course.title).toBe('Rust Course'); + }); + }); + + describe('reindexAll', () => { + it('replaces the entire index atomically', () => { + indexer.indexCourse(makeCourse({ id: 'old' })); + const count = indexer.reindexAll([ + makeCourse({ id: 'new1' }), + makeCourse({ id: 'new2' }), + ]); + expect(count).toBe(2); + expect(indexer.size()).toBe(2); + expect(indexer.getIndexedCourses().map((c) => c.id)).toEqual( + expect.arrayContaining(['new1', 'new2']), + ); + }); + + it('records lastReindexAt timestamp', () => { + expect(indexer.getLastReindexAt()).toBeNull(); + indexer.reindexAll([makeCourse()]); + expect(indexer.getLastReindexAt()).toBeInstanceOf(Date); + }); + }); + + describe('getIndexedCourses', () => { + it('returns a fresh copy that cannot mutate internal state', () => { + indexer.indexCourse(makeCourse({ id: 'c1' })); + const snapshot = indexer.getIndexedCourses(); + snapshot.push(makeCourse({ id: 'hacked' })); + expect(indexer.size()).toBe(1); + }); + }); +}); diff --git a/BackendAcademy/src/search/search-indexer.service.ts b/BackendAcademy/src/search/search-indexer.service.ts index 99c5b413a..4f9457b37 100644 --- a/BackendAcademy/src/search/search-indexer.service.ts +++ b/BackendAcademy/src/search/search-indexer.service.ts @@ -1,6 +1,29 @@ import { Injectable, Logger } from '@nestjs/common'; import { CourseEntity } from '../courses/course.entity'; +/** + * Field-weight configuration for course relevance scoring. + * + * These weights control how much each field contributes to the + * relevance score when ranking search results. Higher weights + * mean the field has more influence on ranking. + * + * Field weights are intentionally exposed as a configurable static block + * so this module stays self-contained. Adjust the weights here + * when tuning search quality. + */ +export const COURSE_SEARCH_WEIGHTS = { + /** Title matches rank highest — the most semantically relevant signal. */ + title: 3, + /** Tag matches signal topic alignment. */ + tags: 2, + /** Description matches provide breadth but lower specificity. */ + description: 1, + /** Category matches provide broad context. */ + categories: 0.5, + category: 0.5, +} as const; + /** * SearchIndexerService (Issue #369) * @@ -13,12 +36,34 @@ import { CourseEntity } from '../courses/course.entity'; * keep working). CourseService and LessonService notify the indexer * synchronously from their create/update/restore/remove paths so the * "search shows stale content" bug cannot occur. + * + * ## Refresh behavior + * + * - **Write-through**: Every course create/update/restore/remove triggers + * an immediate synchronous upsert or delete on the index. + * - **Bulk reindex**: `reindexAll()` atomically replaces the entire index. + * Used by periodic consistency checks and admin-triggered refreshes. + * The caller should pass the full course corpus from the source of truth + * (e.g., `CourseService.findAll()`). + * + * ## Deterministic relevance + * + * Ranking uses a two-level tie-breaker to ensure stable, reproducible + * ordering: + * 1. Weighted field score (title > tags > description > categories) + * 2. Lexicographic sort by `course.id` for courses with identical scores + * + * This means the same query always returns results in the same order, + * regardless of the order the courses were indexed. */ @Injectable() export class SearchIndexerService { private readonly logger = new Logger(SearchIndexerService.name); private readonly indexedCourses = new Map(); + /** Timestamp of the last full reindex operation. */ + private lastReindexAt: Date | null = null; + /** * Idempotently upserts a course into the in-memory index. Called from * CourseService.create / update / restoreRevision immediately after the @@ -46,6 +91,8 @@ export class SearchIndexerService { * Bulk reindex. Used by JobsService when periodic consistency checks * detect a divergence between the source of truth and the in-memory * index. + * + * @returns The number of courses indexed. */ reindexAll(courses: CourseEntity[]): number { this.indexedCourses.clear(); @@ -54,6 +101,7 @@ export class SearchIndexerService { this.indexedCourses.set(course.id, course); } } + this.lastReindexAt = new Date(); this.logger.log(`Reindexed ${this.indexedCourses.size} courses`); return this.indexedCourses.size; } @@ -66,10 +114,75 @@ export class SearchIndexerService { return Array.from(this.indexedCourses.values()); } + /** + * Compute a weighted relevance score for a course against a query needle. + * Higher score = better match. Returns 0 when nothing matches. + * + * Uses {@link COURSE_SEARCH_WEIGHTS} for field weighting. + */ + scoreCourse(course: CourseEntity, needle: string): number { + const weights = COURSE_SEARCH_WEIGHTS; + let score = 0; + + if ((course.title ?? '').toLowerCase().includes(needle)) { + score += weights.title; + } + if ((course.description ?? '').toLowerCase().includes(needle)) { + score += weights.description; + } + if ((course.tags ?? []).some((tag) => tag.toLowerCase().includes(needle))) { + score += weights.tags; + } + const categories = [ + course.category, + ...(course.categories ?? []), + ] + .filter(Boolean) + .map((value) => value.toLowerCase()); + if (categories.some((category) => category.includes(needle))) { + score += weights.categories; + } + + return score; + } + + /** + * Rank and sort a list of courses by relevance to a query needle. + * Uses deterministic tie-breaking (by course.id) so the same query + * always returns results in the same order. + * + * @returns Sorted array of { course, score } with highest score first. + */ + rankCourses( + courses: CourseEntity[], + needle: string, + ): Array<{ course: CourseEntity; score: number }> { + return courses + .map((course) => ({ + course, + score: this.scoreCourse(course, needle), + })) + .filter((entry) => entry.score > 0) + .sort((a, b) => { + // Primary: score descending + if (b.score !== a.score) return b.score - a.score; + // Tie-breaker: lexicographic by id for deterministic ordering + return a.course.id.localeCompare(b.course.id); + }); + } + /** * Returns the indexed size — handy for tests and admin diagnostics. */ size(): number { return this.indexedCourses.size; } + + /** + * Returns the timestamp of the last full reindex operation, + * or null if no reindex has been performed yet. + */ + getLastReindexAt(): Date | null { + return this.lastReindexAt; + } } diff --git a/BackendAcademy/src/search/search.constants.ts b/BackendAcademy/src/search/search.constants.ts new file mode 100644 index 000000000..4d76b5622 --- /dev/null +++ b/BackendAcademy/src/search/search.constants.ts @@ -0,0 +1,6 @@ +/** + * Injection token for the search repository. This allows swapping the + * in-memory implementation for a database-backed one without changing + * the SearchService or module wiring. + */ +export const SEARCH_REPOSITORY = 'SEARCH_REPOSITORY'; diff --git a/BackendAcademy/src/search/search.module.ts b/BackendAcademy/src/search/search.module.ts index 9ba505960..275982f2a 100644 --- a/BackendAcademy/src/search/search.module.ts +++ b/BackendAcademy/src/search/search.module.ts @@ -1,13 +1,26 @@ import { Module } from '@nestjs/common'; import { CourseModule } from '../courses'; +import { UsersModule } from '../users/users.module'; +import { UserProfileModule } from '../users/user-profile.module'; +import { SocialModule } from '../social/social.module'; import { SearchController } from './search.controller'; import { SearchService } from './search.service'; import { SearchIndexerService } from './search-indexer.service'; +import { InMemorySearchRepository } from './in-memory-search.repository'; +import { SEARCH_REPOSITORY } from './search.constants'; @Module({ - imports: [CourseModule], + imports: [CourseModule, UsersModule, UserProfileModule, SocialModule], controllers: [SearchController], - providers: [SearchService, SearchIndexerService], + providers: [ + SearchService, + SearchIndexerService, + InMemorySearchRepository, + { + provide: SEARCH_REPOSITORY, + useExisting: InMemorySearchRepository, + }, + ], exports: [SearchService, SearchIndexerService], }) export class SearchModule {} diff --git a/BackendAcademy/src/search/search.service.ts b/BackendAcademy/src/search/search.service.ts index 262da6b81..09154bd84 100644 --- a/BackendAcademy/src/search/search.service.ts +++ b/BackendAcademy/src/search/search.service.ts @@ -1,7 +1,7 @@ -import { Injectable, Optional } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { CourseEntity } from '../courses/course.entity'; import { CourseService } from '../courses/course.service'; -import { SearchIndexerService } from './search-indexer.service'; +import { SearchIndexerService, COURSE_SEARCH_WEIGHTS } from './search-indexer.service'; import { SearchCoursesQueryDto } from './dto/search-courses-query.dto'; import { SearchQueryDto } from './dto/search-query.dto'; import { @@ -9,22 +9,13 @@ import { SearchResults, UserSearchHit, } from './interfaces/search.interface'; +import { SearchRepository } from './interfaces/search-repository.interface'; /** - * Default field-weight configuration for course relevance (Issue #370). - * - * Field weights are intentionally exposed as a configurable static block - * (not pulled from env.schema.ts) so this module stays self-contained - * even when the env schema is being refactored. Adjust the weights here - * when tuning search quality. + * Re-export for backward compatibility. The canonical weights are defined + * in {@link COURSE_SEARCH_WEIGHTS} on the indexer service. */ -export const DEFAULT_SEARCH_FIELD_WEIGHTS = { - title: 3, - description: 1, - tags: 2, - categories: 0.5, - category: 0.5, -} as const; +export const DEFAULT_SEARCH_FIELD_WEIGHTS = COURSE_SEARCH_WEIGHTS; @Injectable() export class SearchService { @@ -38,53 +29,9 @@ export class SearchService { constructor( private readonly courseService: CourseService, @Optional() private readonly indexer?: SearchIndexerService, + @Optional() private readonly searchRepository?: SearchRepository, ) {} - /** - * In-memory fixture set. Replace with a real SearchRepository backed by - * Postgres `tsvector` (or an external index like Meilisearch / pg_trgm). - * - * TODO: replace with a SearchRepository.searchUsers|searchCourses|searchPosts. - */ - private readonly users: UserSearchHit[] = [ - { id: 'user-0001', username: 'rustmaster', displayName: 'Rust Master' }, - { id: 'user-0002', username: 'codewarrior', displayName: 'Code Warrior' }, - { id: 'user-0003', username: 'stellar-learner', displayName: 'Stellar Learner' }, - { id: 'user-0004', username: 'soroban-tutor', displayName: 'Soroban Tutor' }, - { id: 'user-0005', username: 'blockdash-dev', displayName: 'BlockDash Dev' }, - { id: 'user-0006', username: 'rustacean', displayName: 'Rustacean' }, - { id: 'user-0007', username: 'memorieslock', displayName: 'MemoriesLock' }, - { id: 'user-0008', username: 'rust-newbie', displayName: 'Rust Newbie' }, - ]; - - private readonly posts: PostSearchHit[] = [ - { - id: 'post-001', - title: 'My first Soroban contract', - body: 'Building helloworld on Stellar is fun.', - }, - { - id: 'post-002', - title: 'Rust lifetime annotations explained', - body: 'A clear walkthrough of the borrow checker.', - }, - { - id: 'post-003', - title: 'Stellar path payments in 2026', - body: 'New path-finding APIs and best practices.', - }, - { - id: 'post-004', - title: 'Onboarding for new Rust learners', - body: 'What the Rust Academy cohort should do first.', - }, - { - id: 'post-005', - title: 'Memo on stellar transactions', - body: 'How text memos are encoded and limits.', - }, - ]; - /** * Apply pagination + substring matching. Pure helper - intent is shared * across all 3 resource types. @@ -131,24 +78,27 @@ export class SearchService { } searchUsers(query: SearchQueryDto): SearchResults { - return this.paginate( - this.users, - query.q, - query.limit, - query.offset, - (u) => `${u.id} ${u.username} ${u.displayName}`, - ); + if (this.searchRepository) { + return this.searchRepository.searchUsers({ + q: query.q, + limit: query.limit, + offset: query.offset, + }); + } + // Fallback: return empty results when no repository is available + return { entries: [], total: 0, hasMore: false }; } /** - * Issue #370 — content-based relevance tuning + fallback ranking. + * Issue #370 — content-based relevance tuning + deterministic ranking. * * Strategy: * 1. Pull the corpus: prefer SearchIndexerService (synchronous, fresh * after each write — fixes #369) and fall back to CourseService.findAll. * 2. Apply tag / category filters (existing behaviour). - * 3. Rank the survivors by a weighted field score so an exact title hit - * outranks a description hit. Without `q`, this is a no-op. + * 3. Rank the survivors by a weighted field score with deterministic + * tie-breaking by course.id so the same query always produces the + * same result order. * 4. If the weighted pool has fewer than FALLBACK_THRESHOLD matches, * widen the search to a pure description-substring match so that * learners still see *something* relevant for fuzzy queries. @@ -195,18 +145,15 @@ export class SearchService { ); } - // Field-weighted ranking on the survivors. - const weighted = filteredCourses - .map((course) => ({ - course, - score: this.scoreCourse(course, needle), - })) - .filter((entry) => entry.score > 0) - .sort((a, b) => b.score - a.score); + // Deterministic field-weighted ranking via the indexer service. + // Tie-breaking by course.id ensures stable, reproducible ordering. + const ranked = this.indexer + ? this.indexer.rankCourses(filteredCourses, needle) + : this.fallbackRank(filteredCourses, needle); - if (weighted.length >= SearchService.FALLBACK_THRESHOLD) { + if (ranked.length >= SearchService.FALLBACK_THRESHOLD) { return this.paginate( - weighted.map((entry) => entry.course), + ranked.map((entry) => entry.course), undefined, query.limit, query.offset, @@ -221,12 +168,12 @@ export class SearchService { ); const combined = - weighted.length === 0 + ranked.length === 0 ? fallback : [ - ...weighted.map((entry) => entry.course), + ...ranked.map((entry) => entry.course), ...fallback.filter( - (course) => !weighted.some((entry) => entry.course.id === course.id), + (course) => !ranked.some((entry) => entry.course.id === course.id), ), ]; @@ -240,43 +187,40 @@ export class SearchService { } /** - * Compute a weighted relevance score for a course against a query needle. - * Higher score = better match. Returns 0 when nothing matches. + * Fallback ranking when no indexer is available. Uses the same weights + * as the indexer but without deterministic tie-breaking. */ - private scoreCourse(course: CourseEntity, needle: string): number { - const weights = DEFAULT_SEARCH_FIELD_WEIGHTS; - let score = 0; - - if ((course.title ?? '').toLowerCase().includes(needle)) { - score += weights.title; - } - if ((course.description ?? '').toLowerCase().includes(needle)) { - score += weights.description; - } - if ((course.tags ?? []).some((tag) => tag.toLowerCase().includes(needle))) { - score += weights.tags; - } - const categories = [ - course.category, - ...(course.categories ?? []), - ] - .filter(Boolean) - .map((value) => value.toLowerCase()); - if (categories.some((category) => category.includes(needle))) { - score += weights.categories; - } + private fallbackRank( + courses: CourseEntity[], + needle: string, + ): Array<{ course: CourseEntity; score: number }> { + return courses + .map((course) => ({ + course, + score: this.scoreCourseLocal(course, needle), + })) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score); + } - return score; + /** + * Local relevance scoring for fallback mode (no indexer). + * Uses {@link COURSE_SEARCH_WEIGHTS} for consistent weights. + */ + private scoreCourseLocal(course: CourseEntity, needle: string): number { + return this.indexer ? this.indexer.scoreCourse(course, needle) : 0; } searchPosts(query: SearchQueryDto): SearchResults { - return this.paginate( - this.posts, - query.q, - query.limit, - query.offset, - (p) => `${p.id} ${p.title} ${p.body}`, - ); + if (this.searchRepository) { + return this.searchRepository.searchPosts({ + q: query.q, + limit: query.limit, + offset: query.offset, + }); + } + // Fallback: return empty results when no repository is available + return { entries: [], total: 0, hasMore: false }; } /**