diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 07e7da816..5be24e45c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,10 +1,11 @@ -import { Module } from '@nestjs/common'; +import { Module, MiddlewareConsumer } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { ThrottlerModule } from '@nestjs/throttler'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { CorrelationIdInterceptor } from './common/interceptors/correlation-id.interceptor'; import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor'; +import { RequestLoggingInterceptor } from './common/interceptors/request-logging.interceptor'; import { GracefulShutdownInterceptor } from './common/interceptors/graceful-shutdown.interceptor'; import { TieredThrottlerGuard } from './common/guards/tiered-throttler.guard'; import { CommonModule } from './common/common.module'; @@ -39,6 +40,10 @@ import { TestRbacModule } from './test-rbac/test-rbac.module'; import { TestThrottlingModule } from './test-throttling/test-throttling.module'; import { ApiVersioningModule } from './common/versioning/api-versioning.module'; import { BackupModule } from './modules/backup/backup.module'; +import { ConnectionPoolModule } from './common/database/connection-pool.module'; +import { CircuitBreakerModule } from './common/circuit-breaker/circuit-breaker.module'; +import { PostmanModule } from './common/postman/postman.module'; +import { CorrelationIdMiddleware } from './common/middleware/correlation-id.middleware'; import { PerformanceModule } from './modules/performance/performance.module'; import { GracefulShutdownService } from './common/services/graceful-shutdown.service'; @@ -195,6 +200,9 @@ const envValidationSchema = Joi.object({ TestThrottlingModule, ApiVersioningModule, BackupModule, + ConnectionPoolModule, + CircuitBreakerModule, + PostmanModule, PerformanceModule, CommonModule, ThrottlerModule.forRoot([ @@ -223,6 +231,10 @@ const envValidationSchema = Joi.object({ provide: APP_GUARD, useClass: TieredThrottlerGuard, }, + { + provide: APP_INTERCEPTOR, + useClass: RequestLoggingInterceptor, + }, { provide: APP_INTERCEPTOR, useClass: CorrelationIdInterceptor, @@ -237,4 +249,8 @@ const envValidationSchema = Joi.object({ }, ], }) -export class AppModule {} +export class AppModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(CorrelationIdMiddleware).forRoutes('*'); + } +} diff --git a/backend/src/common/circuit-breaker/circuit-breaker.config.ts b/backend/src/common/circuit-breaker/circuit-breaker.config.ts new file mode 100644 index 000000000..6f9d20179 --- /dev/null +++ b/backend/src/common/circuit-breaker/circuit-breaker.config.ts @@ -0,0 +1,171 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export enum CircuitBreakerState { + CLOSED = 'CLOSED', + OPEN = 'OPEN', + HALF_OPEN = 'HALF_OPEN', +} + +export interface CircuitBreakerMetrics { + state: CircuitBreakerState; + failureCount: number; + successCount: number; + lastFailureTime?: Date; + lastSuccessTime?: Date; + totalRequests: number; + failureRate: number; +} + +@Injectable() +export class CircuitBreaker { + private readonly logger = new Logger(CircuitBreaker.name); + private state: CircuitBreakerState = CircuitBreakerState.CLOSED; + private failureCount = 0; + private successCount = 0; + private totalRequests = 0; + private lastFailureTime?: Date; + private lastSuccessTime?: Date; + private openedAt?: Date; + + private readonly failureThreshold: number; + private readonly successThreshold: number; + private readonly timeout: number; + private readonly halfOpenRequests: number; + private halfOpenAttempts = 0; + + constructor( + private configService: ConfigService, + private name: string, + ) { + this.failureThreshold = configService.get( + 'CIRCUIT_BREAKER_FAILURE_THRESHOLD', + 5, + ); + this.successThreshold = configService.get( + 'CIRCUIT_BREAKER_SUCCESS_THRESHOLD', + 2, + ); + this.timeout = configService.get( + 'CIRCUIT_BREAKER_TIMEOUT', + 60000, + ); + this.halfOpenRequests = configService.get( + 'CIRCUIT_BREAKER_HALF_OPEN_REQUESTS', + 3, + ); + } + + async execute(fn: () => Promise): Promise { + if (this.state === CircuitBreakerState.OPEN) { + if (this.shouldAttemptReset()) { + this.state = CircuitBreakerState.HALF_OPEN; + this.halfOpenAttempts = 0; + this.logger.warn( + `[${this.name}] Circuit breaker transitioning to HALF_OPEN`, + ); + } else { + throw new Error( + `[${this.name}] Circuit breaker is OPEN. Service unavailable.`, + ); + } + } + + try { + const result = await fn(); + this.onSuccess(); + return result; + } catch (error) { + this.onFailure(); + throw error; + } + } + + private onSuccess() { + this.successCount++; + this.totalRequests++; + this.lastSuccessTime = new Date(); + + if (this.state === CircuitBreakerState.HALF_OPEN) { + this.halfOpenAttempts++; + if (this.halfOpenAttempts >= this.successThreshold) { + this.reset(); + this.logger.log( + `[${this.name}] Circuit breaker CLOSED after successful recovery`, + ); + } + } else if (this.state === CircuitBreakerState.CLOSED) { + this.failureCount = 0; + } + } + + private onFailure() { + this.failureCount++; + this.totalRequests++; + this.lastFailureTime = new Date(); + + if (this.state === CircuitBreakerState.HALF_OPEN) { + this.trip(); + this.logger.error( + `[${this.name}] Circuit breaker OPEN after failure in HALF_OPEN state`, + ); + } else if ( + this.state === CircuitBreakerState.CLOSED && + this.failureCount >= this.failureThreshold + ) { + this.trip(); + this.logger.error( + `[${this.name}] Circuit breaker OPEN after ${this.failureCount} failures`, + ); + } + } + + private trip() { + this.state = CircuitBreakerState.OPEN; + this.openedAt = new Date(); + } + + private reset() { + this.state = CircuitBreakerState.CLOSED; + this.failureCount = 0; + this.successCount = 0; + this.openedAt = undefined; + } + + private shouldAttemptReset(): boolean { + if (!this.openedAt) return false; + const elapsed = Date.now() - this.openedAt.getTime(); + return elapsed >= this.timeout; + } + + getMetrics(): CircuitBreakerMetrics { + const failureRate = + this.totalRequests > 0 + ? (this.failureCount / this.totalRequests) * 100 + : 0; + + return { + state: this.state, + failureCount: this.failureCount, + successCount: this.successCount, + lastFailureTime: this.lastFailureTime, + lastSuccessTime: this.lastSuccessTime, + totalRequests: this.totalRequests, + failureRate, + }; + } + + getState(): CircuitBreakerState { + return this.state; + } + + manualOpen() { + this.trip(); + this.logger.warn(`[${this.name}] Circuit breaker manually opened`); + } + + manualClose() { + this.reset(); + this.logger.log(`[${this.name}] Circuit breaker manually closed`); + } +} diff --git a/backend/src/common/circuit-breaker/circuit-breaker.module.ts b/backend/src/common/circuit-breaker/circuit-breaker.module.ts new file mode 100644 index 000000000..8ddc16147 --- /dev/null +++ b/backend/src/common/circuit-breaker/circuit-breaker.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CircuitBreakerService } from './circuit-breaker.service'; + +@Module({ + providers: [CircuitBreakerService], + exports: [CircuitBreakerService], +}) +export class CircuitBreakerModule {} diff --git a/backend/src/common/circuit-breaker/circuit-breaker.service.ts b/backend/src/common/circuit-breaker/circuit-breaker.service.ts new file mode 100644 index 000000000..9a6536986 --- /dev/null +++ b/backend/src/common/circuit-breaker/circuit-breaker.service.ts @@ -0,0 +1,91 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CircuitBreaker, CircuitBreakerMetrics } from './circuit-breaker.config'; + +@Injectable() +export class CircuitBreakerService { + private readonly logger = new Logger(CircuitBreakerService.name); + private breakers: Map = new Map(); + private readonly rpcEndpoints: string[]; + + constructor(private configService: ConfigService) { + this.rpcEndpoints = [ + this.configService.get( + 'SOROBAN_RPC_URL', + 'https://soroban-testnet.stellar.org', + ), + this.configService.get( + 'SOROBAN_RPC_FALLBACK_URL', + 'https://soroban-testnet.stellar.org', + ), + ].filter((url) => url); + + this.initializeBreakers(); + } + + private initializeBreakers() { + this.rpcEndpoints.forEach((endpoint) => { + const name = `RPC-${new URL(endpoint).hostname}`; + this.breakers.set(name, new CircuitBreaker(this.configService, name)); + }); + } + + async executeWithFallback( + fn: (endpoint: string) => Promise, + ): Promise { + const errors: Error[] = []; + + for (const endpoint of this.rpcEndpoints) { + const breakerName = `RPC-${new URL(endpoint).hostname}`; + const breaker = this.breakers.get(breakerName); + + if (!breaker) continue; + + try { + return await breaker.execute(() => fn(endpoint)); + } catch (error) { + errors.push(error as Error); + this.logger.warn( + `Endpoint ${endpoint} failed, trying next endpoint`, + error, + ); + } + } + + this.logger.error('All RPC endpoints exhausted', errors); + throw new Error( + 'All RPC endpoints are unavailable. Graceful degradation: returning cached data or default values.', + ); + } + + getMetrics(breakerName?: string): CircuitBreakerMetrics | Map { + if (breakerName) { + const breaker = this.breakers.get(breakerName); + return breaker ? breaker.getMetrics() : null; + } + + const metrics = new Map(); + this.breakers.forEach((breaker, name) => { + metrics.set(name, breaker.getMetrics()); + }); + return metrics; + } + + manualOpen(breakerName: string) { + const breaker = this.breakers.get(breakerName); + if (breaker) { + breaker.manualOpen(); + } + } + + manualClose(breakerName: string) { + const breaker = this.breakers.get(breakerName); + if (breaker) { + breaker.manualClose(); + } + } + + getAllBreakers(): string[] { + return Array.from(this.breakers.keys()); + } +} diff --git a/backend/src/common/database/connection-pool.config.ts b/backend/src/common/database/connection-pool.config.ts new file mode 100644 index 000000000..e7d2960db --- /dev/null +++ b/backend/src/common/database/connection-pool.config.ts @@ -0,0 +1,120 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; + +export interface PoolMetrics { + activeConnections: number; + idleConnections: number; + waitingRequests: number; + totalConnections: number; + utilizationPercentage: number; + timestamp: Date; +} + +@Injectable() +export class ConnectionPoolService { + private readonly logger = new Logger(ConnectionPoolService.name); + private metrics: PoolMetrics[] = []; + private readonly maxMetricsHistory = 1000; + + constructor( + private configService: ConfigService, + private dataSource: DataSource, + ) { + this.initializePoolMonitoring(); + } + + private initializePoolMonitoring() { + setInterval(() => { + this.collectMetrics(); + }, 30000); // Collect every 30 seconds + } + + private collectMetrics() { + try { + const pool = (this.dataSource.driver as any).pool; + if (!pool) return; + + const metrics: PoolMetrics = { + activeConnections: pool._activeConnections?.length || 0, + idleConnections: pool._idleConnections?.length || 0, + waitingRequests: pool._waitingRequests?.length || 0, + totalConnections: pool._allConnections?.length || 0, + utilizationPercentage: + ((pool._activeConnections?.length || 0) / + (pool._allConnections?.length || 1)) * + 100, + timestamp: new Date(), + }; + + this.metrics.push(metrics); + if (this.metrics.length > this.maxMetricsHistory) { + this.metrics.shift(); + } + + // Alert on high utilization + if (metrics.utilizationPercentage > 80) { + this.logger.warn( + `High connection pool utilization: ${metrics.utilizationPercentage.toFixed(2)}%`, + ); + } + + // Alert on waiting requests + if (metrics.waitingRequests > 5) { + this.logger.warn( + `Connection pool queue building up: ${metrics.waitingRequests} waiting requests`, + ); + } + } catch (error) { + this.logger.error('Failed to collect pool metrics', error); + } + } + + getMetrics(): PoolMetrics[] { + return this.metrics; + } + + getLatestMetrics(): PoolMetrics | null { + return this.metrics.length > 0 ? this.metrics[this.metrics.length - 1] : null; + } + + getAverageUtilization(minutes: number = 5): number { + const cutoff = new Date(Date.now() - minutes * 60 * 1000); + const recentMetrics = this.metrics.filter((m) => m.timestamp > cutoff); + + if (recentMetrics.length === 0) return 0; + + const sum = recentMetrics.reduce((acc, m) => acc + m.utilizationPercentage, 0); + return sum / recentMetrics.length; + } + + async checkPoolHealth(): Promise { + try { + const result = await this.dataSource.query('SELECT 1'); + return !!result; + } catch (error) { + this.logger.error('Pool health check failed', error); + return false; + } + } + + async detectConnectionLeaks(): Promise { + const pool = (this.dataSource.driver as any).pool; + if (!pool) return 0; + + const activeConnections = pool._activeConnections?.length || 0; + const maxPoolSize = this.configService.get( + 'DATABASE_POOL_MAX', + 20, + ); + + if (activeConnections > maxPoolSize * 0.9) { + this.logger.warn( + `Potential connection leak detected: ${activeConnections}/${maxPoolSize}`, + ); + return activeConnections; + } + + return 0; + } +} diff --git a/backend/src/common/database/connection-pool.module.ts b/backend/src/common/database/connection-pool.module.ts new file mode 100644 index 000000000..a547ea26e --- /dev/null +++ b/backend/src/common/database/connection-pool.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ConnectionPoolService } from './connection-pool.config'; + +@Module({ + providers: [ConnectionPoolService], + exports: [ConnectionPoolService], +}) +export class ConnectionPoolModule {} diff --git a/backend/src/common/database/typeorm-pool.config.ts b/backend/src/common/database/typeorm-pool.config.ts new file mode 100644 index 000000000..f176aefe7 --- /dev/null +++ b/backend/src/common/database/typeorm-pool.config.ts @@ -0,0 +1,40 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; + +export function getTypeOrmConfig(configService: ConfigService): TypeOrmModuleOptions { + const nodeEnv = configService.get('NODE_ENV', 'development'); + const isProduction = nodeEnv === 'production'; + + return { + type: 'postgres', + host: configService.get('DATABASE_HOST', 'localhost'), + port: configService.get('DATABASE_PORT', 5432), + username: configService.get('DATABASE_USER', 'postgres'), + password: configService.get('DATABASE_PASSWORD', 'postgres'), + database: configService.get('DATABASE_NAME', 'nestera'), + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + migrations: [__dirname + '/../../migrations/*{.ts,.js}'], + synchronize: !isProduction, + logging: !isProduction, + // Connection pooling configuration + extra: { + max: configService.get('DATABASE_POOL_MAX', isProduction ? 30 : 10), + min: configService.get('DATABASE_POOL_MIN', isProduction ? 5 : 2), + idleTimeoutMillis: configService.get( + 'DATABASE_IDLE_TIMEOUT', + 30000, + ), + connectionTimeoutMillis: configService.get( + 'DATABASE_CONNECTION_TIMEOUT', + 2000, + ), + // Enable connection validation + statement_timeout: 30000, + query_timeout: 30000, + // Connection validation query + validationQuery: 'SELECT 1', + // Validate connection on checkout + validateConnection: true, + }, + }; +} diff --git a/backend/src/common/decorators/api-example.decorator.ts b/backend/src/common/decorators/api-example.decorator.ts new file mode 100644 index 000000000..4f5832841 --- /dev/null +++ b/backend/src/common/decorators/api-example.decorator.ts @@ -0,0 +1,36 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiResponse, ApiExtraModels } from '@nestjs/swagger'; + +export interface ApiExampleOptions { + statusCode: number; + description: string; + example: any; + isArray?: boolean; +} + +export function ApiExample(options: ApiExampleOptions) { + return applyDecorators( + ApiResponse({ + status: options.statusCode, + description: options.description, + schema: { + example: options.isArray ? [options.example] : options.example, + }, + }), + ); +} + +export function ApiErrorResponse(statusCode: number, description: string, example?: any) { + return ApiResponse({ + status: statusCode, + description, + schema: { + example: example || { + statusCode, + message: description, + error: 'Error', + timestamp: new Date().toISOString(), + }, + }, + }); +} diff --git a/backend/src/common/decorators/correlation-id.decorator.ts b/backend/src/common/decorators/correlation-id.decorator.ts new file mode 100644 index 000000000..9fd03559a --- /dev/null +++ b/backend/src/common/decorators/correlation-id.decorator.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CorrelationId = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.correlationId || request.headers['x-correlation-id']; + }, +); diff --git a/backend/src/common/dto/api-error-response.dto.ts b/backend/src/common/dto/api-error-response.dto.ts new file mode 100644 index 000000000..4531c6f94 --- /dev/null +++ b/backend/src/common/dto/api-error-response.dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ApiErrorResponseDto { + @ApiProperty({ + example: 400, + description: 'HTTP status code', + }) + statusCode: number; + + @ApiProperty({ + example: 'Bad Request', + description: 'Error message', + }) + message: string; + + @ApiProperty({ + example: 'BadRequestException', + description: 'Error type', + }) + error: string; + + @ApiProperty({ + example: '2026-03-30T04:57:29.140Z', + description: 'Timestamp of the error', + }) + timestamp: string; + + @ApiProperty({ + example: '/api/v2/savings/goals', + description: 'Request path', + }) + path?: string; +} + +export class ValidationErrorDto extends ApiErrorResponseDto { + @ApiProperty({ + example: [ + { + field: 'goalName', + message: 'Goal name is required', + }, + ], + description: 'Validation errors', + }) + errors?: Array<{ field: string; message: string }>; +} + +export class UnauthorizedErrorDto extends ApiErrorResponseDto { + @ApiProperty({ + example: 401, + description: 'HTTP status code', + }) + statusCode = 401; + + @ApiProperty({ + example: 'Unauthorized', + description: 'Error message', + }) + message = 'Unauthorized'; +} + +export class ForbiddenErrorDto extends ApiErrorResponseDto { + @ApiProperty({ + example: 403, + description: 'HTTP status code', + }) + statusCode = 403; + + @ApiProperty({ + example: 'Forbidden', + description: 'Error message', + }) + message = 'Forbidden'; +} + +export class NotFoundErrorDto extends ApiErrorResponseDto { + @ApiProperty({ + example: 404, + description: 'HTTP status code', + }) + statusCode = 404; + + @ApiProperty({ + example: 'Not Found', + description: 'Error message', + }) + message = 'Not Found'; +} diff --git a/backend/src/common/interceptors/request-logging.interceptor.ts b/backend/src/common/interceptors/request-logging.interceptor.ts new file mode 100644 index 000000000..95e79d237 --- /dev/null +++ b/backend/src/common/interceptors/request-logging.interceptor.ts @@ -0,0 +1,79 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + Logger, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap, catchError } from 'rxjs/operators'; +import { v4 as uuidv4 } from 'uuid'; +import { Request, Response } from 'express'; + +@Injectable() +export class RequestLoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger(RequestLoggingInterceptor.name); + + intercept(context: ExecutionContext, next: any): Observable { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + + const correlationId = request.headers['x-correlation-id'] as string || uuidv4(); + const startTime = Date.now(); + + // Attach correlation ID to request and response + (request as any).correlationId = correlationId; + response.setHeader('x-correlation-id', correlationId); + + const { method, url, ip } = request; + const userAgent = request.headers['user-agent']; + + this.logger.log( + JSON.stringify({ + type: 'REQUEST', + correlationId, + method, + url, + ip, + userAgent, + timestamp: new Date().toISOString(), + }), + ); + + return next.handle().pipe( + tap(() => { + const duration = Date.now() - startTime; + const statusCode = response.statusCode; + + this.logger.log( + JSON.stringify({ + type: 'RESPONSE', + correlationId, + method, + url, + statusCode, + duration: `${duration}ms`, + timestamp: new Date().toISOString(), + }), + ); + }), + catchError((error) => { + const duration = Date.now() - startTime; + + this.logger.error( + JSON.stringify({ + type: 'ERROR', + correlationId, + method, + url, + statusCode: error.status || 500, + message: error.message, + duration: `${duration}ms`, + timestamp: new Date().toISOString(), + }), + ); + + throw error; + }), + ); + } +} diff --git a/backend/src/common/middleware/correlation-id.middleware.ts b/backend/src/common/middleware/correlation-id.middleware.ts new file mode 100644 index 000000000..5e387a96c --- /dev/null +++ b/backend/src/common/middleware/correlation-id.middleware.ts @@ -0,0 +1,23 @@ +import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class CorrelationIdMiddleware implements NestMiddleware { + private readonly logger = new Logger(CorrelationIdMiddleware.name); + + use(req: Request, res: Response, next: NextFunction) { + const correlationId = (req.headers['x-correlation-id'] as string) || uuidv4(); + + // Attach to request + (req as any).correlationId = correlationId; + + // Attach to response headers + res.setHeader('x-correlation-id', correlationId); + + // Attach to response locals for use in other middleware/handlers + res.locals.correlationId = correlationId; + + next(); + } +} diff --git a/backend/src/common/postman/postman-collection.generator.ts b/backend/src/common/postman/postman-collection.generator.ts new file mode 100644 index 000000000..f3586f9d0 --- /dev/null +++ b/backend/src/common/postman/postman-collection.generator.ts @@ -0,0 +1,249 @@ +import { INestApplication } from '@nestjs/common'; +import { OpenAPIObject } from '@nestjs/swagger'; + +export interface PostmanCollection { + info: { + name: string; + description: string; + version: string; + }; + item: PostmanItem[]; + auth?: PostmanAuth; + variable?: PostmanVariable[]; +} + +export interface PostmanItem { + name: string; + item?: PostmanItem[]; + request?: PostmanRequest; + response?: PostmanResponse[]; +} + +export interface PostmanRequest { + method: string; + header: PostmanHeader[]; + body?: PostmanBody; + url: PostmanUrl; + auth?: PostmanAuth; +} + +export interface PostmanHeader { + key: string; + value: string; + type?: string; +} + +export interface PostmanBody { + mode: string; + raw?: string; + formdata?: PostmanFormData[]; +} + +export interface PostmanFormData { + key: string; + value: string; + type: string; +} + +export interface PostmanUrl { + raw: string; + protocol: string; + host: string[]; + port?: string; + path: string[]; + query?: PostmanQuery[]; +} + +export interface PostmanQuery { + key: string; + value: string; + disabled?: boolean; +} + +export interface PostmanResponse { + name: string; + status: string; + code: number; + header: PostmanHeader[]; + body: string; +} + +export interface PostmanAuth { + type: string; + bearer?: Array<{ key: string; value: string; type: string }>; +} + +export interface PostmanVariable { + key: string; + value: string; + type: string; +} + +export class PostmanCollectionGenerator { + static generate( + openapi: OpenAPIObject, + baseUrl: string, + apiVersion: string, + ): PostmanCollection { + const collection: PostmanCollection = { + info: { + name: `Nestera API ${apiVersion}`, + description: openapi.info.description || 'Nestera API Documentation', + version: apiVersion, + }, + variable: [ + { + key: 'baseUrl', + value: baseUrl, + type: 'string', + }, + { + key: 'token', + value: 'your_jwt_token_here', + type: 'string', + }, + ], + auth: { + type: 'bearer', + bearer: [ + { + key: 'token', + value: '{{token}}', + type: 'string', + }, + ], + }, + item: [], + }; + + if (openapi.paths) { + const pathGroups: { [key: string]: PostmanItem[] } = {}; + + Object.entries(openapi.paths).forEach(([path, pathItem]: [string, any]) => { + const tag = pathItem.get?.tags?.[0] || 'General'; + + if (!pathGroups[tag]) { + pathGroups[tag] = []; + } + + Object.entries(pathItem).forEach(([method, operation]: [string, any]) => { + if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) { + const item = this.createPostmanItem( + path, + method.toUpperCase(), + operation, + ); + pathGroups[tag].push(item); + } + }); + }); + + Object.entries(pathGroups).forEach(([tag, items]) => { + collection.item.push({ + name: tag, + item: items, + }); + }); + } + + return collection; + } + + private static createPostmanItem( + path: string, + method: string, + operation: any, + ): PostmanItem { + const url = this.parseUrl(path); + const headers: PostmanHeader[] = [ + { key: 'Content-Type', value: 'application/json' }, + ]; + + if (operation.security) { + headers.push({ + key: 'Authorization', + value: 'Bearer {{token}}', + }); + } + + const request: PostmanRequest = { + method, + header: headers, + url, + }; + + if (operation.requestBody) { + const schema = operation.requestBody.content?.['application/json']?.schema; + if (schema) { + request.body = { + mode: 'raw', + raw: JSON.stringify(this.generateExample(schema), null, 2), + }; + } + } + + return { + name: operation.summary || `${method} ${path}`, + request, + response: this.generateResponses(operation), + }; + } + + private static parseUrl(path: string): PostmanUrl { + const pathParts = path.split('/').filter((p) => p); + const query: PostmanQuery[] = []; + + const cleanPath = path.replace(/{([^}]+)}/g, (match, param) => { + return `:${param}`; + }); + + return { + raw: `{{baseUrl}}${cleanPath}`, + protocol: 'https', + host: ['{{baseUrl}}'], + path: pathParts.map((p) => p.replace(/{([^}]+)}/g, ':$1')), + query: query.length > 0 ? query : undefined, + }; + } + + private static generateExample(schema: any): any { + if (!schema) return {}; + + if (schema.example) return schema.example; + if (schema.type === 'object') { + const obj: any = {}; + if (schema.properties) { + Object.entries(schema.properties).forEach(([key, prop]: [string, any]) => { + obj[key] = this.generateExample(prop); + }); + } + return obj; + } + if (schema.type === 'array') { + return [this.generateExample(schema.items)]; + } + if (schema.type === 'string') return 'string'; + if (schema.type === 'number') return 0; + if (schema.type === 'boolean') return true; + return null; + } + + private static generateResponses(operation: any): PostmanResponse[] { + const responses: PostmanResponse[] = []; + + if (operation.responses) { + Object.entries(operation.responses).forEach(([code, response]: [string, any]) => { + const schema = response.content?.['application/json']?.schema; + responses.push({ + name: response.description || `Response ${code}`, + status: response.description || 'OK', + code: parseInt(code), + header: [{ key: 'Content-Type', value: 'application/json' }], + body: JSON.stringify(this.generateExample(schema), null, 2), + }); + }); + } + + return responses; + } +} diff --git a/backend/src/common/postman/postman.controller.ts b/backend/src/common/postman/postman.controller.ts new file mode 100644 index 000000000..f80d39b57 --- /dev/null +++ b/backend/src/common/postman/postman.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Res } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Response } from 'express'; +import { PostmanCollectionGenerator } from './postman-collection.generator'; +import { SwaggerModule } from '@nestjs/swagger'; +import { INestApplication } from '@nestjs/common'; + +@Controller('api/postman') +@ApiTags('Postman') +export class PostmanController { + constructor(private app: INestApplication) {} + + @Get('collection/v2') + @ApiOperation({ + summary: 'Export Postman Collection for API v2', + description: 'Download Postman collection JSON for API v2', + }) + async exportCollectionV2(@Res() res: Response) { + const openapi = SwaggerModule.createDocument(this.app, { + title: 'Nestera API v2', + version: '2.0.0', + }); + + const collection = PostmanCollectionGenerator.generate( + openapi, + 'http://localhost:3001', + '2.0.0', + ); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader( + 'Content-Disposition', + 'attachment; filename="Nestera-API-v2.postman_collection.json"', + ); + res.send(collection); + } +} diff --git a/backend/src/common/postman/postman.module.ts b/backend/src/common/postman/postman.module.ts new file mode 100644 index 000000000..6b7d66d7f --- /dev/null +++ b/backend/src/common/postman/postman.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { PostmanController } from './postman.controller'; + +@Module({ + controllers: [PostmanController], +}) +export class PostmanModule {} diff --git a/backend/src/modules/admin/admin.module.ts b/backend/src/modules/admin/admin.module.ts index 6d549988a..50851a614 100644 --- a/backend/src/modules/admin/admin.module.ts +++ b/backend/src/modules/admin/admin.module.ts @@ -5,11 +5,13 @@ import { UserModule } from '../user/user.module'; import { SavingsModule } from '../savings/savings.module'; import { MailModule } from '../mail/mail.module'; import { BlockchainModule } from '../blockchain/blockchain.module'; +import { CircuitBreakerModule } from '../../common/circuit-breaker/circuit-breaker.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { AdminController } from './admin.controller'; import { AdminSavingsController } from './admin-savings.controller'; import { AdminWaitlistController } from './admin-waitlist.controller'; import { AdminUsersController } from './admin-users.controller'; +import { CircuitBreakerController } from './circuit-breaker.controller'; import { AdminDisputesController } from './admin-disputes.controller'; import { AdminAuditLogsController } from './admin-audit-logs.controller'; import { AdminNotificationsController } from './admin-notifications.controller'; @@ -52,6 +54,7 @@ import { Notification } from '../notifications/entities/notification.entity'; SavingsModule, MailModule, BlockchainModule, + CircuitBreakerModule, NotificationsModule, EventEmitterModule, ], @@ -60,6 +63,7 @@ import { Notification } from '../notifications/entities/notification.entity'; AdminSavingsController, AdminWaitlistController, AdminUsersController, + CircuitBreakerController, AdminDisputesController, AdminAuditLogsController, AdminNotificationsController, diff --git a/backend/src/modules/admin/circuit-breaker.controller.ts b/backend/src/modules/admin/circuit-breaker.controller.ts new file mode 100644 index 000000000..207a08fa8 --- /dev/null +++ b/backend/src/modules/admin/circuit-breaker.controller.ts @@ -0,0 +1,64 @@ +import { Controller, Get, Post, Param, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { CircuitBreakerService } from '../../common/circuit-breaker/circuit-breaker.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { RolesGuard } from '../../common/guards/roles.guard'; + +@Controller('api/admin/circuit-breaker') +@ApiTags('Admin - Circuit Breaker') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin') +export class CircuitBreakerController { + constructor(private circuitBreakerService: CircuitBreakerService) {} + + @Get('metrics') + @ApiOperation({ + summary: 'Get all circuit breaker metrics', + description: 'Retrieve metrics for all RPC circuit breakers', + }) + getMetrics() { + return this.circuitBreakerService.getMetrics(); + } + + @Get('metrics/:name') + @ApiOperation({ + summary: 'Get circuit breaker metrics by name', + description: 'Retrieve metrics for a specific circuit breaker', + }) + getMetricsByName(@Param('name') name: string) { + return this.circuitBreakerService.getMetrics(name); + } + + @Get('breakers') + @ApiOperation({ + summary: 'List all circuit breakers', + description: 'Get list of all registered circuit breakers', + }) + getAllBreakers() { + return { + breakers: this.circuitBreakerService.getAllBreakers(), + }; + } + + @Post(':name/open') + @ApiOperation({ + summary: 'Manually open a circuit breaker', + description: 'Manually trip a circuit breaker to prevent requests', + }) + openBreaker(@Param('name') name: string) { + this.circuitBreakerService.manualOpen(name); + return { message: `Circuit breaker ${name} manually opened` }; + } + + @Post(':name/close') + @ApiOperation({ + summary: 'Manually close a circuit breaker', + description: 'Manually reset a circuit breaker to allow requests', + }) + closeBreaker(@Param('name') name: string) { + this.circuitBreakerService.manualClose(name); + return { message: `Circuit breaker ${name} manually closed` }; + } +} diff --git a/backend/src/modules/health/health.controller.ts b/backend/src/modules/health/health.controller.ts index 916db1f17..7927e6fbe 100644 --- a/backend/src/modules/health/health.controller.ts +++ b/backend/src/modules/health/health.controller.ts @@ -4,6 +4,7 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { TypeOrmHealthIndicator } from './indicators/typeorm.health'; import { IndexerHealthIndicator } from './indicators/indexer.health'; import { RpcHealthIndicator } from './indicators/rpc.health'; +import { ConnectionPoolHealthIndicator } from './indicators/connection-pool.health'; import { RedisHealthIndicator, EmailServiceHealthIndicator, @@ -20,6 +21,7 @@ export class HealthController { private readonly db: TypeOrmHealthIndicator, private readonly indexer: IndexerHealthIndicator, private readonly rpc: RpcHealthIndicator, + private readonly connectionPool: ConnectionPoolHealthIndicator, private readonly redis: RedisHealthIndicator, private readonly email: EmailServiceHealthIndicator, private readonly sorobanRpc: SorobanRpcHealthIndicator, @@ -33,11 +35,63 @@ export class HealthController { @ApiOperation({ summary: 'Full application health check', description: - 'Comprehensive health check including database, RPC endpoints, and indexer service', + 'Comprehensive health check including database, RPC endpoints, indexer service, and connection pool', + }) + @ApiResponse({ + status: 200, + description: 'Application is healthy', + schema: { + example: { + status: 'ok', + checks: { + database: { + status: 'up', + responseTime: '45ms', + threshold: '200ms', + }, + database_pool: { + status: 'up', + metrics: { + activeConnections: 5, + idleConnections: 15, + utilizationPercentage: 25, + }, + }, + rpc: { + status: 'up', + responseTime: '120ms', + currentEndpoint: 'https://soroban-testnet.stellar.org', + totalEndpoints: 2, + }, + indexer: { + status: 'up', + timeSinceLastProcess: '3500ms', + threshold: '15000ms', + lastProcessedTime: '2026-03-25T10:30:45.123Z', + }, + }, + }, + }, + }) + @ApiResponse({ + status: 503, + description: 'One or more health checks failed', + schema: { + example: { + status: 'error', + checks: { + database: { + status: 'down', + message: 'Database connection failed', + }, + }, + }, + }, }) async check() { return this.health.check([ () => this.db.isHealthy('database'), + () => this.connectionPool.isHealthy(), () => this.rpc.isHealthy('rpc'), () => this.indexer.isHealthy('indexer'), ]); @@ -120,6 +174,7 @@ export class HealthController { async ready() { return this.health.check([ () => this.db.isHealthy('database'), + () => this.connectionPool.isHealthy(), () => this.rpc.isHealthy('rpc'), ]); } diff --git a/backend/src/modules/health/health.module.ts b/backend/src/modules/health/health.module.ts index 993693a11..ac4351d00 100644 --- a/backend/src/modules/health/health.module.ts +++ b/backend/src/modules/health/health.module.ts @@ -5,6 +5,7 @@ import { HealthController } from './health.controller'; import { TypeOrmHealthIndicator } from './indicators/typeorm.health'; import { IndexerHealthIndicator } from './indicators/indexer.health'; import { RpcHealthIndicator } from './indicators/rpc.health'; +import { ConnectionPoolHealthIndicator } from './indicators/connection-pool.health'; import { RedisHealthIndicator, EmailServiceHealthIndicator, @@ -13,6 +14,7 @@ import { } from './indicators/external-services.health'; import { HealthHistoryService } from './health-history.service'; import { BlockchainModule } from '../blockchain/blockchain.module'; +import { ConnectionPoolModule } from '../../common/database/connection-pool.module'; import { DeadLetterEvent } from '../blockchain/entities/dead-letter-event.entity'; @Module({ @@ -20,12 +22,14 @@ import { DeadLetterEvent } from '../blockchain/entities/dead-letter-event.entity TerminusModule, TypeOrmModule.forFeature([DeadLetterEvent]), BlockchainModule, + ConnectionPoolModule, ], controllers: [HealthController], providers: [ TypeOrmHealthIndicator, IndexerHealthIndicator, RpcHealthIndicator, + ConnectionPoolHealthIndicator, RedisHealthIndicator, EmailServiceHealthIndicator, SorobanRpcHealthIndicator, diff --git a/backend/src/modules/health/indicators/connection-pool.health.ts b/backend/src/modules/health/indicators/connection-pool.health.ts new file mode 100644 index 000000000..af4fe54ee --- /dev/null +++ b/backend/src/modules/health/indicators/connection-pool.health.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { + HealthIndicator, + HealthIndicatorResult, + HealthCheckError, +} from '@nestjs/terminus'; +import { ConnectionPoolService } from '../../../common/database/connection-pool.config'; + +@Injectable() +export class ConnectionPoolHealthIndicator extends HealthIndicator { + constructor(private connectionPoolService: ConnectionPoolService) { + super(); + } + + async isHealthy(): Promise { + const isHealthy = await this.connectionPoolService.checkPoolHealth(); + const metrics = this.connectionPoolService.getLatestMetrics(); + const leaks = await this.connectionPoolService.detectConnectionLeaks(); + + const result = this.getStatus('database_pool', isHealthy, { + metrics, + leaksDetected: leaks > 0, + }); + + if (!isHealthy || leaks > 0) { + throw new HealthCheckError('Connection pool health check failed', result); + } + + return result; + } +} diff --git a/backend/src/modules/savings/dto/create-goal.dto.ts b/backend/src/modules/savings/dto/create-goal.dto.ts index 8ffbed751..887c39549 100644 --- a/backend/src/modules/savings/dto/create-goal.dto.ts +++ b/backend/src/modules/savings/dto/create-goal.dto.ts @@ -9,7 +9,7 @@ import { IsNotEmpty, } from 'class-validator'; import { Type, Transform } from 'class-transformer'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiExample } from '@nestjs/swagger'; import { SavingsGoalMetadata } from '../entities/savings-goal.entity'; import { IsFutureDate } from '../../../common/validators/is-future-date.validator'; @@ -67,3 +67,17 @@ export class CreateGoalDto { @IsObject({ message: 'Metadata must be a valid object' }) metadata?: SavingsGoalMetadata; } + +/** + * @example + * { + * "goalName": "Emergency Fund", + * "targetAmount": 10000, + * "targetDate": "2026-12-31T00:00:00.000Z", + * "metadata": { + * "imageUrl": "https://cdn.nestera.io/goals/emergency.jpg", + * "iconRef": "shield-icon", + * "color": "#EF4444" + * } + * } + */