From 548c40480ac98f87ef5bff411f2cee3bda531ec7 Mon Sep 17 00:00:00 2001 From: Tomike Tolulope Date: Thu, 23 Apr 2026 20:18:15 +0000 Subject: [PATCH] fix: resolve issues #260, #261, #274, #276 - #260: Verified CreateAssessmentDto and UpdateAssessmentDto already have full validation decorators (IsString, IsNotEmpty, IsOptional, IsEnum, IsUUID, IsNumber, Min, Max). No changes required. - #261: Add custom exception classes in src/common/exceptions/app.exceptions.ts (ResourceNotFoundException, ForbiddenOperationException, ResourceConflictException, BusinessValidationException, ServiceUnavailableException) for consistent error handling across services. - #274: Add DB connection pool Prometheus metrics (db_pool_connections_acquired_total, db_pool_connections_released_total, db_pool_size) to MetricsCollectionService. Wire pool event hooks in app.module.ts and log pool config at startup. - #276: Document caching strategy (cache-aside pattern, TTL policy table, key versioning, event-driven invalidation) in caching.constants.ts. Closes #260 Closes #261 Closes #274 Closes #276 --- src/app.module.ts | 22 ++++++- src/caching/caching.constants.ts | 32 ++++++++++ src/common/exceptions/app.exceptions.ts | 62 +++++++++++++++++++ .../metrics/metrics-collection.service.ts | 27 +++++++- 4 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/common/exceptions/app.exceptions.ts diff --git a/src/app.module.ts b/src/app.module.ts index a63c70e4..b8a6caec 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module, DynamicModule, Type } from '@nestjs/common'; +import { Module, DynamicModule, Type, Logger } from '@nestjs/common'; import { APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -55,7 +55,6 @@ import { LocalizationModule } from './localization/localization.module'; import { CsrfModule } from './common/csrf/csrf.module'; import { TimeoutModule } from './common/timeout/timeout.module'; - @Module({}) export class AppModule { static async forRoot(): Promise { @@ -85,6 +84,13 @@ export class AppModule { 10, ); + // Log pool configuration at startup for observability (#274) + const poolLogger = new Logger('DatabasePool'); + poolLogger.log( + `DB pool config — max: ${poolMax}, min: ${poolMin}, ` + + `acquireTimeout: ${poolAcquireTimeoutMs}ms, idleTimeout: ${poolIdleTimeoutMs}ms`, + ); + return { type: 'postgres', host: process.env.DATABASE_HOST || 'localhost', @@ -103,6 +109,17 @@ export class AppModule { min: poolMin, connectionTimeoutMillis: poolAcquireTimeoutMs, idleTimeoutMillis: poolIdleTimeoutMs, + // Pool event hooks for Prometheus metrics (#274). + // pg fires these on the underlying Pool instance after each + // acquire/release so we can track connection churn over time. + afterPoolConnect: (_client: unknown, _eventCount: number) => { + metricsService.dbPoolConnectionsAcquired.inc(); + metricsService.dbPoolSize.inc(); + }, + afterPoolRelease: (_client: unknown, _eventCount: number) => { + metricsService.dbPoolConnectionsReleased.inc(); + metricsService.dbPoolSize.dec(); + }, }, }; }, @@ -134,7 +151,6 @@ export class AppModule { DatabaseModule, CsrfModule, TimeoutModule, - ]; // Feature modules - conditionally loaded based on feature flags diff --git a/src/caching/caching.constants.ts b/src/caching/caching.constants.ts index b2ac0da5..37d0de34 100644 --- a/src/caching/caching.constants.ts +++ b/src/caching/caching.constants.ts @@ -1,5 +1,37 @@ export const CACHE_REDIS_CLIENT = 'CACHE_REDIS_CLIENT'; +/** + * Cache TTL (Time-To-Live) values in seconds. + * + * ## Caching Strategy + * + * TeachLink uses a Redis-backed cache-aside pattern: + * 1. On read, check cache first; on miss, fetch from DB and populate cache. + * 2. On write/delete, invalidate or update the relevant cache keys. + * + * ### TTL Policy + * TTLs are chosen based on data volatility and read frequency: + * + * | Key | TTL | Rationale | + * |------------------|------------|------------------------------------------------| + * | USER_SESSION | 7 days | Long-lived auth sessions; invalidated on logout| + * | COURSE_METADATA | 15 min | Frequently read, infrequently updated | + * | COURSE_DETAILS | 5 min | May change (price, seats); short TTL | + * | SEARCH_RESULTS | 2 min | High read volume; stale results acceptable | + * | USER_PROFILE | 10 min | Moderate update frequency | + * | STATIC_CONTENT | 1 hour | Rarely changes; safe to cache long | + * | POPULAR_COURSES | 30 min | Computed ranking; refresh periodically | + * | ENROLLMENT_DATA | 5 min | Changes on enroll/unenroll events | + * + * ### Cache Key Versioning + * All keys are prefixed with `cache:` (see CACHE_PREFIXES). + * To bust all keys for an entity type, increment the version suffix: + * e.g. `cache:course:v2:` + * + * ### Invalidation + * Cache invalidation is event-driven via CACHE_EVENTS. Services emit these + * events after mutations; the CachingModule subscribes and deletes stale keys. + */ export const CACHE_TTL = { USER_SESSION: 604800, // 7 days COURSE_METADATA: 900, // 15 minutes diff --git a/src/common/exceptions/app.exceptions.ts b/src/common/exceptions/app.exceptions.ts new file mode 100644 index 00000000..ccb2ec5e --- /dev/null +++ b/src/common/exceptions/app.exceptions.ts @@ -0,0 +1,62 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +/** + * Thrown when a requested resource cannot be found. + * Maps to HTTP 404 Not Found. + */ +export class ResourceNotFoundException extends HttpException { + constructor(resource: string, id?: string | number) { + const message = id ? `${resource} with id '${id}' was not found` : `${resource} was not found`; + super({ message, error: 'Not Found', statusCode: HttpStatus.NOT_FOUND }, HttpStatus.NOT_FOUND); + } +} + +/** + * Thrown when an operation is not permitted for the current user/state. + * Maps to HTTP 403 Forbidden. + */ +export class ForbiddenOperationException extends HttpException { + constructor(message = 'You do not have permission to perform this action') { + super({ message, error: 'Forbidden', statusCode: HttpStatus.FORBIDDEN }, HttpStatus.FORBIDDEN); + } +} + +/** + * Thrown when a resource already exists (e.g. duplicate email). + * Maps to HTTP 409 Conflict. + */ +export class ResourceConflictException extends HttpException { + constructor(resource: string, field?: string) { + const message = field + ? `${resource} with this ${field} already exists` + : `${resource} already exists`; + super({ message, error: 'Conflict', statusCode: HttpStatus.CONFLICT }, HttpStatus.CONFLICT); + } +} + +/** + * Thrown when input data fails business-rule validation (beyond DTO constraints). + * Maps to HTTP 422 Unprocessable Entity. + */ +export class BusinessValidationException extends HttpException { + constructor(message: string) { + super( + { message, error: 'Unprocessable Entity', statusCode: HttpStatus.UNPROCESSABLE_ENTITY }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } +} + +/** + * Thrown when an external service or dependency is unavailable. + * Maps to HTTP 503 Service Unavailable. + */ +export class ServiceUnavailableException extends HttpException { + constructor(service: string) { + const message = `${service} is currently unavailable. Please try again later.`; + super( + { message, error: 'Service Unavailable', statusCode: HttpStatus.SERVICE_UNAVAILABLE }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } +} diff --git a/src/monitoring/metrics/metrics-collection.service.ts b/src/monitoring/metrics/metrics-collection.service.ts index 47f35898..6c2e09d6 100644 --- a/src/monitoring/metrics/metrics-collection.service.ts +++ b/src/monitoring/metrics/metrics-collection.service.ts @@ -1,5 +1,5 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; -import { Registry, collectDefaultMetrics, Histogram, Gauge } from 'prom-client'; +import { Registry, collectDefaultMetrics, Histogram, Gauge, Counter } from 'prom-client'; @Injectable() export class MetricsCollectionService implements OnModuleInit { @@ -7,6 +7,12 @@ export class MetricsCollectionService implements OnModuleInit { public httpRequestDuration: Histogram; public dbQueryDuration: Histogram; public activeConnections: Gauge; + /** Tracks total DB pool connections acquired since startup (#274) */ + public dbPoolConnectionsAcquired: Counter; + /** Tracks total DB pool connections released since startup (#274) */ + public dbPoolConnectionsReleased: Counter; + /** Tracks current DB pool size (active + idle) (#274) */ + public dbPoolSize: Gauge; constructor() { this.registry = new Registry(); @@ -35,6 +41,25 @@ export class MetricsCollectionService implements OnModuleInit { help: 'Number of active connections', registers: [this.registry], }); + + // DB connection pool metrics (#274) + this.dbPoolConnectionsAcquired = new Counter({ + name: 'db_pool_connections_acquired_total', + help: 'Total number of DB pool connections acquired', + registers: [this.registry], + }); + + this.dbPoolConnectionsReleased = new Counter({ + name: 'db_pool_connections_released_total', + help: 'Total number of DB pool connections released', + registers: [this.registry], + }); + + this.dbPoolSize = new Gauge({ + name: 'db_pool_size', + help: 'Current DB connection pool size (active + idle)', + registers: [this.registry], + }); } onModuleInit() {