From c981bff7710bb64ab6013233e87ab9328d0d46b0 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 00:18:21 +0700 Subject: [PATCH] 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 --- apps/api/src/main.ts | 8 +- .../domain/__tests__/domain-exception.spec.ts | 74 ++++++++++++++ .../modules/shared/domain/domain-exception.ts | 56 +++++++++++ .../src/modules/shared/domain/error-codes.ts | 36 +++++++ apps/api/src/modules/shared/domain/index.ts | 10 ++ .../__tests__/global-exception.filter.spec.ts | 98 +++++++++++++++++++ .../__tests__/pii-masker.spec.ts | 62 ++++++++++++ .../filters/global-exception.filter.ts | 83 ++++++++++++++++ .../modules/shared/infrastructure/index.ts | 4 + .../shared/infrastructure/logger.service.ts | 7 ++ .../middleware/correlation-id.middleware.ts | 15 +++ .../middleware/request-logging.middleware.ts | 40 ++++++++ .../shared/infrastructure/pii-masker.ts | 53 ++++++++++ apps/api/src/modules/shared/shared.module.ts | 23 ++++- 14 files changed, 564 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/modules/shared/domain/__tests__/domain-exception.spec.ts create mode 100644 apps/api/src/modules/shared/domain/domain-exception.ts create mode 100644 apps/api/src/modules/shared/domain/error-codes.ts create mode 100644 apps/api/src/modules/shared/infrastructure/__tests__/global-exception.filter.spec.ts create mode 100644 apps/api/src/modules/shared/infrastructure/__tests__/pii-masker.spec.ts create mode 100644 apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts create mode 100644 apps/api/src/modules/shared/infrastructure/middleware/correlation-id.middleware.ts create mode 100644 apps/api/src/modules/shared/infrastructure/middleware/request-logging.middleware.ts create mode 100644 apps/api/src/modules/shared/infrastructure/pii-masker.ts diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0d1530c..adb68ed 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,11 +1,15 @@ import { NestFactory } from '@nestjs/core'; +import { LoggerService } from '@modules/shared'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { bufferLogs: true }); + const logger = app.get(LoggerService); + app.useLogger(logger); + const port = process.env['PORT'] ?? 3001; await app.listen(port); - console.log(`API running on http://localhost:${port}`); + logger.log(`API running on http://localhost:${port}`, 'Bootstrap'); } bootstrap(); diff --git a/apps/api/src/modules/shared/domain/__tests__/domain-exception.spec.ts b/apps/api/src/modules/shared/domain/__tests__/domain-exception.spec.ts new file mode 100644 index 0000000..9683406 --- /dev/null +++ b/apps/api/src/modules/shared/domain/__tests__/domain-exception.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/shared/domain/domain-exception.ts b/apps/api/src/modules/shared/domain/domain-exception.ts new file mode 100644 index 0000000..7ca5e5b --- /dev/null +++ b/apps/api/src/modules/shared/domain/domain-exception.ts @@ -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; + 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, + ) { + 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) { + 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); + } +} diff --git a/apps/api/src/modules/shared/domain/error-codes.ts b/apps/api/src/modules/shared/domain/error-codes.ts new file mode 100644 index 0000000..68d250e --- /dev/null +++ b/apps/api/src/modules/shared/domain/error-codes.ts @@ -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', +} diff --git a/apps/api/src/modules/shared/domain/index.ts b/apps/api/src/modules/shared/domain/index.ts index 76106d2..3c045df 100644 --- a/apps/api/src/modules/shared/domain/index.ts +++ b/apps/api/src/modules/shared/domain/index.ts @@ -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'; diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/global-exception.filter.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/global-exception.filter.spec.ts new file mode 100644 index 0000000..f893ea3 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/__tests__/global-exception.filter.spec.ts @@ -0,0 +1,98 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import { DomainException } from '../../domain/domain-exception'; +import { ErrorCode } from '../../domain/error-codes'; +import { GlobalExceptionFilter } from '../filters/global-exception.filter'; +import { LoggerService } from '../logger.service'; + +function createMockHost(correlationId?: string) { + const json = vi.fn(); + const status = vi.fn().mockReturnValue({ json }); + const request = { + headers: { 'x-correlation-id': correlationId }, + }; + const response = { status }; + + return { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => response, + }), + json, + status, + }; +} + +describe('GlobalExceptionFilter', () => { + const logger = { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + verbose: vi.fn(), + child: vi.fn(), + } as unknown as LoggerService; + const filter = new GlobalExceptionFilter(logger); + + it('should handle DomainException with correct error code', () => { + const exception = new DomainException( + ErrorCode.USER_NOT_FOUND, + 'User not found', + HttpStatus.NOT_FOUND, + ); + const host = createMockHost('test-corr-id'); + + filter.catch(exception, host as never); + + expect(host.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + expect(host.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.NOT_FOUND, + errorCode: ErrorCode.USER_NOT_FOUND, + message: 'User not found', + correlationId: 'test-corr-id', + }), + ); + }); + + it('should handle standard HttpException', () => { + const exception = new HttpException('Bad request', HttpStatus.BAD_REQUEST); + const host = createMockHost(); + + filter.catch(exception, host as never); + + expect(host.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(host.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.BAD_REQUEST, + errorCode: ErrorCode.BAD_REQUEST, + message: 'Bad request', + }), + ); + }); + + it('should handle unknown errors as 500', () => { + const exception = new Error('Something broke'); + const host = createMockHost(); + + filter.catch(exception, host as never); + + expect(host.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(host.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.INTERNAL_ERROR, + message: 'Internal server error', + }), + ); + }); + + it('should include timestamp in every response', () => { + const exception = new Error('test'); + const host = createMockHost(); + + filter.catch(exception, host as never); + + expect(host.json).toHaveBeenCalledWith(expect.objectContaining({ timestamp: expect.any(String) })); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/pii-masker.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/pii-masker.spec.ts new file mode 100644 index 0000000..fdf90d3 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/__tests__/pii-masker.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { maskPii } from '../pii-masker'; + +describe('maskPii', () => { + it('should redact sensitive keys', () => { + const input = { + username: 'john', + password: 'secret123', + token: 'abc-def', + authorization: 'Bearer xyz', + }; + const result = maskPii(input) as Record; + expect(result['username']).toBe('john'); + expect(result['password']).toBe('[REDACTED]'); + expect(result['token']).toBe('[REDACTED]'); + expect(result['authorization']).toBe('[REDACTED]'); + }); + + it('should mask email addresses in strings', () => { + const input = 'Contact user@example.com for details'; + const result = maskPii(input); + expect(result).toBe('Contact u***@example.com for details'); + }); + + it('should mask Vietnamese phone numbers', () => { + const input = 'Call 0912345678 or +84912345678'; + const result = maskPii(input) as string; + expect(result).not.toContain('0912345678'); + expect(result).not.toContain('+84912345678'); + expect(result).toContain('091****678'); + expect(result).toContain('+84****678'); + }); + + it('should handle nested objects', () => { + const input = { + user: { + name: 'John', + password: 'secret', + email: 'john@test.com', + }, + }; + const result = maskPii(input) as Record>; + expect(result['user']!['name']).toBe('John'); + expect(result['user']!['password']).toBe('[REDACTED]'); + }); + + it('should handle arrays', () => { + const input = [{ password: 'a' }, { password: 'b' }]; + const result = maskPii(input) as Record[]; + expect(result[0]!['password']).toBe('[REDACTED]'); + expect(result[1]!['password']).toBe('[REDACTED]'); + }); + + it('should return null/undefined as-is', () => { + expect(maskPii(null)).toBeNull(); + expect(maskPii(undefined)).toBeUndefined(); + }); + + it('should return numbers as-is', () => { + expect(maskPii(42)).toBe(42); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts b/apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts new file mode 100644 index 0000000..4a0ecb1 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts @@ -0,0 +1,83 @@ +import { + type ArgumentsHost, + Catch, + type ExceptionFilter, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { DomainException, type ErrorResponseBody } from '../../domain/domain-exception'; +import { ErrorCode } from '../../domain/error-codes'; +import { LoggerService } from '../logger.service'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + constructor(private readonly logger: LoggerService) {} + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const correlationId = (request.headers['x-correlation-id'] as string) ?? undefined; + + const errorResponse = this.buildErrorResponse(exception, correlationId); + + this.logger.error( + `[${errorResponse.errorCode}] ${errorResponse.message}`, + exception instanceof Error ? exception.stack : undefined, + 'GlobalExceptionFilter', + ); + + response.status(errorResponse.statusCode).json(errorResponse); + } + + private buildErrorResponse(exception: unknown, correlationId?: string): ErrorResponseBody { + if (exception instanceof DomainException) { + return { + statusCode: exception.getStatus(), + errorCode: exception.errorCode, + message: exception.message, + details: exception.details, + correlationId, + timestamp: new Date().toISOString(), + }; + } + + if (exception instanceof HttpException) { + const status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + const message = + typeof exceptionResponse === 'string' + ? exceptionResponse + : (exceptionResponse as Record)['message'] ?? exception.message; + + return { + statusCode: status, + errorCode: this.httpStatusToErrorCode(status), + message: Array.isArray(message) ? message.join('; ') : String(message), + correlationId, + timestamp: new Date().toISOString(), + }; + } + + return { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.INTERNAL_ERROR, + message: 'Internal server error', + correlationId, + timestamp: new Date().toISOString(), + }; + } + + private httpStatusToErrorCode(status: number): ErrorCode { + const map: Record = { + [HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST, + [HttpStatus.UNAUTHORIZED]: ErrorCode.UNAUTHORIZED, + [HttpStatus.FORBIDDEN]: ErrorCode.FORBIDDEN, + [HttpStatus.NOT_FOUND]: ErrorCode.NOT_FOUND, + [HttpStatus.CONFLICT]: ErrorCode.CONFLICT, + [HttpStatus.TOO_MANY_REQUESTS]: ErrorCode.TOO_MANY_REQUESTS, + }; + return map[status] ?? ErrorCode.INTERNAL_ERROR; + } +} diff --git a/apps/api/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts index 97301c1..d10b45d 100644 --- a/apps/api/src/modules/shared/infrastructure/index.ts +++ b/apps/api/src/modules/shared/infrastructure/index.ts @@ -2,3 +2,7 @@ export { PrismaService } from './prisma.service'; export { RedisService } from './redis.service'; export { LoggerService } from './logger.service'; export { EventBusService } from './event-bus.service'; +export { GlobalExceptionFilter } from './filters/global-exception.filter'; +export { CorrelationIdMiddleware } from './middleware/correlation-id.middleware'; +export { RequestLoggingMiddleware } from './middleware/request-logging.middleware'; +export { maskPii } from './pii-masker'; diff --git a/apps/api/src/modules/shared/infrastructure/logger.service.ts b/apps/api/src/modules/shared/infrastructure/logger.service.ts index 9339e17..1b5a4c7 100644 --- a/apps/api/src/modules/shared/infrastructure/logger.service.ts +++ b/apps/api/src/modules/shared/infrastructure/logger.service.ts @@ -1,5 +1,6 @@ import { Injectable, type LoggerService as NestLoggerService } from '@nestjs/common'; import pino, { type Logger } from 'pino'; +import { maskPii } from './pii-masker'; @Injectable() export class LoggerService implements NestLoggerService { @@ -12,6 +13,12 @@ export class LoggerService implements NestLoggerService { process.env['NODE_ENV'] !== 'production' ? { target: 'pino-pretty', options: { colorize: true } } : undefined, + formatters: { + log(object) { + return maskPii(object) as Record; + }, + }, + timestamp: pino.stdTimeFunctions.isoTime, }); } diff --git a/apps/api/src/modules/shared/infrastructure/middleware/correlation-id.middleware.ts b/apps/api/src/modules/shared/infrastructure/middleware/correlation-id.middleware.ts new file mode 100644 index 0000000..580e43b --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/middleware/correlation-id.middleware.ts @@ -0,0 +1,15 @@ +import { Injectable, type NestMiddleware } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import type { NextFunction, Request, Response } from 'express'; + +const CORRELATION_ID_HEADER = 'x-correlation-id'; + +@Injectable() +export class CorrelationIdMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction): void { + const correlationId = (req.headers[CORRELATION_ID_HEADER] as string) || randomUUID(); + req.headers[CORRELATION_ID_HEADER] = correlationId; + res.setHeader(CORRELATION_ID_HEADER, correlationId); + next(); + } +} diff --git a/apps/api/src/modules/shared/infrastructure/middleware/request-logging.middleware.ts b/apps/api/src/modules/shared/infrastructure/middleware/request-logging.middleware.ts new file mode 100644 index 0000000..40ff398 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/middleware/request-logging.middleware.ts @@ -0,0 +1,40 @@ +import { Injectable, type NestMiddleware } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; +import { LoggerService } from '../logger.service'; + +@Injectable() +export class RequestLoggingMiddleware implements NestMiddleware { + private readonly pinoChild; + + constructor(private readonly logger: LoggerService) { + this.pinoChild = this.logger.child({ component: 'http' }); + } + + use(req: Request, res: Response, next: NextFunction): void { + const start = Date.now(); + const correlationId = req.headers['x-correlation-id'] as string | undefined; + + res.on('finish', () => { + const duration = Date.now() - start; + const logData = { + method: req.method, + url: req.originalUrl, + statusCode: res.statusCode, + duration, + correlationId, + userAgent: req.headers['user-agent'], + ip: req.ip, + }; + + if (res.statusCode >= 500) { + this.pinoChild.error(logData, 'request completed'); + } else if (res.statusCode >= 400) { + this.pinoChild.warn(logData, 'request completed'); + } else { + this.pinoChild.info(logData, 'request completed'); + } + }); + + next(); + } +} diff --git a/apps/api/src/modules/shared/infrastructure/pii-masker.ts b/apps/api/src/modules/shared/infrastructure/pii-masker.ts new file mode 100644 index 0000000..9ea19ba --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/pii-masker.ts @@ -0,0 +1,53 @@ +/** + * PII masking utility for structured logs. + * Redacts sensitive fields in log data before they reach the transport. + */ + +const SENSITIVE_KEYS = new Set([ + 'password', + 'token', + 'accessToken', + 'refreshToken', + 'secret', + 'authorization', + 'cookie', + 'creditCard', + 'cardNumber', + 'cvv', + 'ssn', + 'cmnd', + 'cccd', +]); + +const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g; +const PHONE_REGEX = /(?:\+84|0)\d{9,10}/g; + +export function maskPii(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj; + if (typeof obj === 'string') return maskString(obj); + if (Array.isArray(obj)) return obj.map(maskPii); + if (typeof obj === 'object') { + const masked: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if (SENSITIVE_KEYS.has(key)) { + masked[key] = '[REDACTED]'; + } else { + masked[key] = maskPii(value); + } + } + return masked; + } + return obj; +} + +function maskString(value: string): string { + let result = value; + result = result.replace(EMAIL_REGEX, (match) => { + const [local, domain] = match.split('@'); + return `${local![0]}***@${domain}`; + }); + result = result.replace(PHONE_REGEX, (match) => { + return match.slice(0, 3) + '****' + match.slice(-3); + }); + return result; +} diff --git a/apps/api/src/modules/shared/shared.module.ts b/apps/api/src/modules/shared/shared.module.ts index a9b5d54..10a9ef7 100644 --- a/apps/api/src/modules/shared/shared.module.ts +++ b/apps/api/src/modules/shared/shared.module.ts @@ -1,14 +1,31 @@ -import { Global, Module } from '@nestjs/common'; +import { Global, type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventBusService } from './infrastructure/event-bus.service'; +import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter'; import { LoggerService } from './infrastructure/logger.service'; +import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation-id.middleware'; +import { RequestLoggingMiddleware } from './infrastructure/middleware/request-logging.middleware'; import { PrismaService } from './infrastructure/prisma.service'; import { RedisService } from './infrastructure/redis.service'; @Global() @Module({ imports: [EventEmitterModule.forRoot()], - providers: [PrismaService, RedisService, LoggerService, EventBusService], + providers: [ + PrismaService, + RedisService, + LoggerService, + EventBusService, + { + provide: APP_FILTER, + useClass: GlobalExceptionFilter, + }, + ], exports: [PrismaService, RedisService, LoggerService, EventBusService], }) -export class SharedModule {} +export class SharedModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(CorrelationIdMiddleware, RequestLoggingMiddleware).forRoutes('*'); + } +}