diff --git a/src/common/errors/app.exception.ts b/src/common/errors/app.exception.ts new file mode 100644 index 0000000..829b5aa --- /dev/null +++ b/src/common/errors/app.exception.ts @@ -0,0 +1,61 @@ +import { HttpException, HttpStatus } from "@nestjs/common"; +import { ErrorCode } from "./error-codes"; + +export interface ErrorResponse { + statusCode: number; + errorCode: ErrorCode; + message: string | object; + correlationId: string; + timestamp: string; + path: string; + errors?: Record; +} + +export class AppException extends HttpException { + public readonly errorCode: ErrorCode; + + constructor( + message: string, + status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode = ErrorCode.INTERNAL_ERROR, + ) { + super(message, status); + this.errorCode = errorCode; + } +} + +export class NotFoundException extends AppException { + constructor(message = "Resource not found") { + super(message, HttpStatus.NOT_FOUND, ErrorCode.NOT_FOUND); + } +} + +export class UnauthorizedException extends AppException { + constructor(message = "Unauthorized") { + super(message, HttpStatus.UNAUTHORIZED, ErrorCode.UNAUTHORIZED); + } +} + +export class ForbiddenException extends AppException { + constructor(message = "Forbidden") { + super(message, HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN); + } +} + +export class BadRequestException extends AppException { + constructor(message = "Bad request") { + super(message, HttpStatus.BAD_REQUEST, ErrorCode.VALIDATION_ERROR); + } +} + +export class ConflictException extends AppException { + constructor(message = "Resource conflict") { + super(message, HttpStatus.CONFLICT, ErrorCode.CONFLICT); + } +} + +export class RateLimitException extends AppException { + constructor(message = "Too many requests") { + super(message, HttpStatus.TOO_MANY_REQUESTS, ErrorCode.RATE_LIMITED); + } +} diff --git a/src/common/errors/error-codes.ts b/src/common/errors/error-codes.ts new file mode 100644 index 0000000..aef4e04 --- /dev/null +++ b/src/common/errors/error-codes.ts @@ -0,0 +1,20 @@ +export enum ErrorCode { + // Generic + INTERNAL_ERROR = "INTERNAL_ERROR", + NOT_FOUND = "NOT_FOUND", + VALIDATION_ERROR = "VALIDATION_ERROR", + RATE_LIMITED = "RATE_LIMITED", + + // Auth + UNAUTHORIZED = "UNAUTHORIZED", + FORBIDDEN = "FORBIDDEN", + TOKEN_EXPIRED = "TOKEN_EXPIRED", + INVALID_CREDENTIALS = "INVALID_CREDENTIALS", + ACCOUNT_LOCKED = "ACCOUNT_LOCKED", + + // Business + CONFLICT = "CONFLICT", + RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND", + DUPLICATE_ENTRY = "DUPLICATE_ENTRY", + PRECONDITION_FAILED = "PRECONDITION_FAILED", +} diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts index 6c4dd9f..320cd8d 100644 --- a/src/common/filters/global-exception.filter.ts +++ b/src/common/filters/global-exception.filter.ts @@ -12,13 +12,13 @@ import { import { ConfigService } from "@nestjs/config"; import { Request, Response } from "express"; import { v4 as uuidv4 } from "uuid"; +import { AppException } from "../errors/app.exception"; +import { ErrorCode } from "../errors/error-codes"; + +interface ValidationConstraints { + [property: string]: string[]; +} -/** - * Global exception filter that sanitizes error responses in production. - * - Never exposes stack traces or internal details to clients. - * - Logs full error details server-side with a correlation ID. - * - Returns structured, generic error responses to clients. - */ @Catch() @Injectable() export class GlobalExceptionFilter implements ExceptionFilter { @@ -35,52 +35,124 @@ export class GlobalExceptionFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); - const correlationId = uuidv4(); + const correlationId = + (request.headers["x-request-id"] as string) || uuidv4(); let status = HttpStatus.INTERNAL_SERVER_ERROR; let clientMessage: string | object = "An unexpected error occurred"; + let errorCode: ErrorCode = ErrorCode.INTERNAL_ERROR; + let errors: ValidationConstraints | undefined; - if (exception instanceof HttpException) { + if (exception instanceof AppException) { + status = exception.getStatus(); + errorCode = exception.errorCode; + clientMessage = this.extractMessage(exception); + } else if (exception instanceof HttpException) { status = exception.getStatus(); const exceptionResponse = exception.getResponse(); - // In production, only expose safe HTTP exception messages - if (this.isProduction) { - clientMessage = - typeof exceptionResponse === "string" - ? exceptionResponse - : ((exceptionResponse as any).message ?? "Request failed"); + if ( + status === HttpStatus.BAD_REQUEST && + typeof exceptionResponse === "object" + ) { + const response = exceptionResponse as any; + if (Array.isArray(response.message)) { + errors = this.formatValidationErrors(response.message); + clientMessage = "Validation failed"; + errorCode = ErrorCode.VALIDATION_ERROR; + } else { + clientMessage = this.isProduction + ? (response.message ?? "Request failed") + : exceptionResponse; + errorCode = this.mapStatusToCode(status); + } } else { - clientMessage = - typeof exceptionResponse === "string" + clientMessage = this.isProduction + ? typeof exceptionResponse === "string" ? exceptionResponse - : exceptionResponse; + : ((exceptionResponse as any).message ?? "Request failed") + : exceptionResponse; + errorCode = this.mapStatusToCode(status); } } - // Report production-grade errors to Sentry for grouping, breadcrumbs, and alerting. - if (Sentry.getCurrentHub().getClient()) { - Sentry.withScope((scope) => { - scope.setTag("http.method", request.method); - scope.setTag("http.status_code", String(status)); - scope.setTag("correlation_id", correlationId); - scope.setExtra("path", request.url); - scope.setExtra("query", request.query); - scope.setExtra("params", request.params); - scope.setUser({ ip_address: request.ip }); - - const level: SeverityLevel = status >= 500 ? "error" : "warning"; - scope.setLevel(level); - - const errorToCapture = - exception instanceof Error ? exception : new Error(String(exception)); - if (status >= 500 || status === 401 || status === 403) { - Sentry.captureException(errorToCapture); - } - }); + this.reportToSentry(exception, status, correlationId, request); + this.logError(exception, status, correlationId, request); + + response.status(status).json({ + statusCode: status, + errorCode, + message: clientMessage, + ...(errors && { errors }), + correlationId, + timestamp: new Date().toISOString(), + path: request.url, + }); + } + + private extractMessage(exception: HttpException): string { + const response = exception.getResponse(); + if (typeof response === "string") return response; + return (response as any).message ?? "Request failed"; + } + + private formatValidationErrors(messages: string[]): ValidationConstraints { + const errors: ValidationConstraints = {}; + for (const msg of messages) { + const match = msg.match(/^(\w+)\s/); + const field = match ? match[1] : "body"; + if (!errors[field]) errors[field] = []; + errors[field].push(msg); } + return errors; + } - // Log full details server-side only + private mapStatusToCode(status: number): ErrorCode { + const map: Record = { + 400: ErrorCode.VALIDATION_ERROR, + 401: ErrorCode.UNAUTHORIZED, + 403: ErrorCode.FORBIDDEN, + 404: ErrorCode.NOT_FOUND, + 409: ErrorCode.CONFLICT, + 429: ErrorCode.RATE_LIMITED, + }; + return map[status] ?? ErrorCode.INTERNAL_ERROR; + } + + private reportToSentry( + exception: unknown, + status: number, + correlationId: string, + request: Request, + ): void { + if (!Sentry.getCurrentHub().getClient()) return; + + Sentry.withScope((scope) => { + scope.setTag("http.method", request.method); + scope.setTag("http.status_code", String(status)); + scope.setTag("correlation_id", correlationId); + scope.setExtra("path", request.url); + scope.setExtra("query", request.query); + scope.setExtra("params", request.params); + scope.setUser({ ip_address: request.ip }); + + const level: SeverityLevel = status >= 500 ? "error" : "warning"; + scope.setLevel(level); + + const errorToCapture = + exception instanceof Error ? exception : new Error(String(exception)); + if (status >= 500 || status === 401 || status === 403) { + Sentry.captureException(errorToCapture); + } + }); + } + + private logError( + exception: unknown, + status: number, + correlationId: string, + request: Request, + ): void { this.logger.error({ correlationId, method: request.method, @@ -95,16 +167,5 @@ export class GlobalExceptionFilter implements ExceptionFilter { } : String(exception), }); - - response.status(status).json({ - statusCode: status, - correlationId, - message: clientMessage, - timestamp: new Date().toISOString(), - path: request.url, - }); } } - - - diff --git a/src/common/index.ts b/src/common/index.ts index 778a585..c14582a 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,3 +1,16 @@ +// Errors +export { ErrorCode } from "./errors/error-codes"; +export { + AppException, + NotFoundException, + UnauthorizedException, + ForbiddenException, + BadRequestException, + ConflictException, + RateLimitException, +} from "./errors/app.exception"; +export type { ErrorResponse } from "./errors/app.exception"; + // Guards export { RolesGuard } from "./guard/roles.guard"; export { KycGuard } from "./guard/kyc.guard";