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:
Ho Ngoc Hai
2026-04-08 00:18:21 +07:00
parent 1fb7bb39d2
commit c981bff771
14 changed files with 564 additions and 5 deletions

View File

@@ -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);
});
});

View 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);
}
}

View 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',
}

View File

@@ -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';