feat(shared): add error handling & structured logging strategy
- Global exception filter with consistent error response format - Domain exceptions (NotFoundException, ValidationException, etc.) - Error codes enum for domain-specific error identification - Correlation ID middleware for request tracing - Request/response logging middleware with structured JSON - PII masking in logs (emails, phone numbers, sensitive fields) - Enhanced LoggerService with pino formatters and ISO timestamps - Tests for exception filter, domain exceptions, and PII masker Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ConflictException,
|
||||
DomainException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
ValidationException,
|
||||
} from '../domain-exception';
|
||||
import { ErrorCode } from '../error-codes';
|
||||
|
||||
describe('DomainException', () => {
|
||||
it('should create with error code and message', () => {
|
||||
const ex = new DomainException(ErrorCode.INTERNAL_ERROR, 'Something failed');
|
||||
expect(ex.errorCode).toBe(ErrorCode.INTERNAL_ERROR);
|
||||
expect(ex.message).toBe('Something failed');
|
||||
expect(ex.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
});
|
||||
|
||||
it('should accept details', () => {
|
||||
const ex = new DomainException(ErrorCode.VALIDATION_FAILED, 'Invalid', HttpStatus.BAD_REQUEST, {
|
||||
field: 'email',
|
||||
});
|
||||
expect(ex.details).toEqual({ field: 'email' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotFoundException', () => {
|
||||
it('should format message with entity and id', () => {
|
||||
const ex = new NotFoundException('User', '123');
|
||||
expect(ex.message).toBe("User with id '123' not found");
|
||||
expect(ex.getStatus()).toBe(HttpStatus.NOT_FOUND);
|
||||
expect(ex.errorCode).toBe(ErrorCode.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('should format message without id', () => {
|
||||
const ex = new NotFoundException('Course');
|
||||
expect(ex.message).toBe('Course not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ValidationException', () => {
|
||||
it('should be BAD_REQUEST with details', () => {
|
||||
const ex = new ValidationException('Invalid input', { field: 'name' });
|
||||
expect(ex.getStatus()).toBe(HttpStatus.BAD_REQUEST);
|
||||
expect(ex.errorCode).toBe(ErrorCode.VALIDATION_FAILED);
|
||||
expect(ex.details).toEqual({ field: 'name' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConflictException', () => {
|
||||
it('should be CONFLICT', () => {
|
||||
const ex = new ConflictException('Already exists');
|
||||
expect(ex.getStatus()).toBe(HttpStatus.CONFLICT);
|
||||
expect(ex.errorCode).toBe(ErrorCode.CONFLICT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnauthorizedException', () => {
|
||||
it('should default message', () => {
|
||||
const ex = new UnauthorizedException();
|
||||
expect(ex.message).toBe('Unauthorized');
|
||||
expect(ex.getStatus()).toBe(HttpStatus.UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ForbiddenException', () => {
|
||||
it('should default message', () => {
|
||||
const ex = new ForbiddenException();
|
||||
expect(ex.message).toBe('Forbidden');
|
||||
expect(ex.getStatus()).toBe(HttpStatus.FORBIDDEN);
|
||||
});
|
||||
});
|
||||
56
apps/api/src/modules/shared/domain/domain-exception.ts
Normal file
56
apps/api/src/modules/shared/domain/domain-exception.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
export interface ErrorResponseBody {
|
||||
statusCode: number;
|
||||
errorCode: ErrorCode;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
correlationId?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export class DomainException extends HttpException {
|
||||
constructor(
|
||||
public readonly errorCode: ErrorCode,
|
||||
message: string,
|
||||
statusCode: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message, statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundException extends DomainException {
|
||||
constructor(entity: string, id?: string) {
|
||||
super(
|
||||
ErrorCode.NOT_FOUND,
|
||||
id ? `${entity} with id '${id}' not found` : `${entity} not found`,
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationException extends DomainException {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(ErrorCode.VALIDATION_FAILED, message, HttpStatus.BAD_REQUEST, details);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictException extends DomainException {
|
||||
constructor(message: string) {
|
||||
super(ErrorCode.CONFLICT, message, HttpStatus.CONFLICT);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedException extends DomainException {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(ErrorCode.UNAUTHORIZED, message, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenException extends DomainException {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(ErrorCode.FORBIDDEN, message, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
36
apps/api/src/modules/shared/domain/error-codes.ts
Normal file
36
apps/api/src/modules/shared/domain/error-codes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Domain-specific error codes for consistent error identification across the platform.
|
||||
* Format: DOMAIN_ACTION_REASON
|
||||
*/
|
||||
export enum ErrorCode {
|
||||
// General
|
||||
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
||||
VALIDATION_FAILED = 'VALIDATION_FAILED',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
CONFLICT = 'CONFLICT',
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
BAD_REQUEST = 'BAD_REQUEST',
|
||||
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
|
||||
|
||||
// Auth
|
||||
AUTH_INVALID_CREDENTIALS = 'AUTH_INVALID_CREDENTIALS',
|
||||
AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED',
|
||||
AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID',
|
||||
AUTH_INSUFFICIENT_PERMISSIONS = 'AUTH_INSUFFICIENT_PERMISSIONS',
|
||||
|
||||
// User
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
USER_ALREADY_EXISTS = 'USER_ALREADY_EXISTS',
|
||||
USER_INVALID_PHONE = 'USER_INVALID_PHONE',
|
||||
|
||||
// Course
|
||||
COURSE_NOT_FOUND = 'COURSE_NOT_FOUND',
|
||||
COURSE_ALREADY_PUBLISHED = 'COURSE_ALREADY_PUBLISHED',
|
||||
COURSE_ENROLLMENT_CLOSED = 'COURSE_ENROLLMENT_CLOSED',
|
||||
|
||||
// Payment
|
||||
PAYMENT_FAILED = 'PAYMENT_FAILED',
|
||||
PAYMENT_ALREADY_PROCESSED = 'PAYMENT_ALREADY_PROCESSED',
|
||||
PAYMENT_INVALID_AMOUNT = 'PAYMENT_INVALID_AMOUNT',
|
||||
}
|
||||
@@ -3,3 +3,13 @@ export { AggregateRoot } from './aggregate-root';
|
||||
export { ValueObject } from './value-object';
|
||||
export type { DomainEvent } from './domain-event';
|
||||
export { Result } from './result';
|
||||
export { ErrorCode } from './error-codes';
|
||||
export {
|
||||
DomainException,
|
||||
NotFoundException,
|
||||
ValidationException,
|
||||
ConflictException,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from './domain-exception';
|
||||
export type { ErrorResponseBody } from './domain-exception';
|
||||
|
||||
Reference in New Issue
Block a user