From 961ee7234344912ecd585c1366fe99ea4a3ce35a Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 6 Apr 2026 23:10:56 +0700 Subject: [PATCH] 690406:2310 Done Task BE-ERR-01 --- backend/src/common/common.module.ts | 4 +- .../src/common/exceptions/base.exception.ts | 235 ++++++++++++++++++ backend/src/common/exceptions/index.ts | 19 ++ .../common/filters/global-exception.filter.ts | 216 ++++++++++++++++ .../circulation/circulation.service.ts | 22 +- .../correspondence.service.spec.ts | 4 +- .../correspondence/correspondence.service.ts | 91 +++---- .../modules/dashboard/dashboard.service.ts | 11 +- .../services/document-numbering.service.ts | 10 +- .../json-schema/json-schema.service.ts | 31 ++- .../services/schema-migration.service.ts | 16 +- .../json-schema/services/ui-schema.service.ts | 9 +- .../modules/migration/migration.service.ts | 54 ++-- backend/src/modules/rfa/rfa.service.ts | 147 +++++++---- .../transmittal/transmittal.service.ts | 22 +- .../modules/user/user-assignment.service.ts | 7 +- backend/src/modules/user/user.service.ts | 23 +- .../workflow-engine/dsl/parser.service.ts | 57 +++-- .../workflow-engine/workflow-dsl.service.ts | 75 ++++-- .../workflow-engine.service.ts | 57 +++-- docs/error-catalog.md | 192 ++++++++++++++ frontend/components/common/error-display.tsx | 217 ++++++++++++++++ frontend/lib/api/client.ts | 76 +++++- .../ADR-020-ai-intelligence-integration.md | 2 +- 24 files changed, 1329 insertions(+), 268 deletions(-) create mode 100644 backend/src/common/exceptions/base.exception.ts create mode 100644 backend/src/common/exceptions/index.ts create mode 100644 backend/src/common/filters/global-exception.filter.ts create mode 100644 docs/error-catalog.md create mode 100644 frontend/components/common/error-display.tsx diff --git a/backend/src/common/common.module.ts b/backend/src/common/common.module.ts index b4315a2..729c055 100644 --- a/backend/src/common/common.module.ts +++ b/backend/src/common/common.module.ts @@ -7,7 +7,7 @@ import { CryptoService } from './services/crypto.service'; import { RequestContextService } from './services/request-context.service'; import { UuidResolverService } from './services/uuid-resolver.service'; import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; -import { HttpExceptionFilter } from './exceptions/http-exception.filter'; +import { GlobalExceptionFilter } from './filters/global-exception.filter'; import { TransformInterceptor } from './interceptors/transform.interceptor'; // import { IdempotencyInterceptor } from './interceptors/idempotency.interceptor'; // นำเข้าถ้าต้องการใช้ Global @@ -21,7 +21,7 @@ import { TransformInterceptor } from './interceptors/transform.interceptor'; // Register Global Filter & Interceptor ที่นี่ หรือใน AppModule ก็ได้ { provide: APP_FILTER, - useClass: HttpExceptionFilter, + useClass: GlobalExceptionFilter, }, { provide: APP_INTERCEPTOR, diff --git a/backend/src/common/exceptions/base.exception.ts b/backend/src/common/exceptions/base.exception.ts new file mode 100644 index 0000000..d379a12 --- /dev/null +++ b/backend/src/common/exceptions/base.exception.ts @@ -0,0 +1,235 @@ +// File: src/common/exceptions/base.exception.ts +// ADR-007: Exception hierarchy สำหรับ Layered Error Handling + +import { HttpException, HttpStatus } from '@nestjs/common'; + +// ประเภทของ Error ที่ระบบรองรับ +export enum ErrorType { + VALIDATION = 'VALIDATION', + BUSINESS_RULE = 'BUSINESS_RULE', + PERMISSION_DENIED = 'PERMISSION_DENIED', + NOT_FOUND = 'NOT_FOUND', + CONFLICT = 'CONFLICT', + INTERNAL_ERROR = 'INTERNAL_ERROR', + DATABASE_ERROR = 'DATABASE_ERROR', + EXTERNAL_SERVICE = 'EXTERNAL_SERVICE', + INFRASTRUCTURE = 'INFRASTRUCTURE', +} + +// ระดับความรุนแรงของ Error +export enum ErrorSeverity { + LOW = 'LOW', // ผู้ใช้ทำผิด แก้ไขง่าย + MEDIUM = 'MEDIUM', // ละเมิดกฎทางธุรกิจ ต้องดำเนินการ + HIGH = 'HIGH', // ปัญหาระบบ อาจต้องติดต่อ Support + CRITICAL = 'CRITICAL', // ระบบล้มเหลว ต้องแก้ไขทันที +} + +// รายละเอียด Validation Error แต่ละ Field +export interface ValidationErrorDetail { + field: string; + message: string; + value?: unknown; +} + +// แปลง ErrorType เป็น HTTP Status Code +export function getStatusCode(type: ErrorType): number { + switch (type) { + case ErrorType.VALIDATION: + return HttpStatus.BAD_REQUEST; + case ErrorType.BUSINESS_RULE: + return HttpStatus.UNPROCESSABLE_ENTITY; + case ErrorType.PERMISSION_DENIED: + return HttpStatus.FORBIDDEN; + case ErrorType.NOT_FOUND: + return HttpStatus.NOT_FOUND; + case ErrorType.CONFLICT: + return HttpStatus.CONFLICT; + case ErrorType.INTERNAL_ERROR: + case ErrorType.DATABASE_ERROR: + case ErrorType.EXTERNAL_SERVICE: + case ErrorType.INFRASTRUCTURE: + return HttpStatus.INTERNAL_SERVER_ERROR; + default: + return HttpStatus.INTERNAL_SERVER_ERROR; + } +} + +// รูปแบบ Payload ของ Error Response +export interface ErrorPayload { + type: ErrorType; + code: string; + message: string; + severity: ErrorSeverity; + timestamp: string; + recoveryActions?: string[]; + technicalMessage?: string; + details?: ValidationErrorDetail[] | Record; +} + +// Base Exception ที่ทุก Custom Exception ต้อง extends +export abstract class BaseException extends HttpException { + public readonly httpStatus: number; + + constructor( + public readonly type: ErrorType, + public readonly code: string, + public readonly technicalMessage: string, + public readonly userMessage?: string, + public readonly severity: ErrorSeverity = ErrorSeverity.MEDIUM, + public readonly details?: ValidationErrorDetail[] | Record, + public readonly recoveryActions?: string[] + ) { + const httpStatus = getStatusCode(type); + + const payload: ErrorPayload = { + type, + code, + message: userMessage || technicalMessage, + severity, + timestamp: new Date().toISOString(), + ...(recoveryActions && { recoveryActions }), + ...(process.env['NODE_ENV'] !== 'production' && { + technicalMessage, + ...(details && { details }), + }), + }; + + super({ error: payload }, httpStatus); + this.httpStatus = httpStatus; + } +} + +// Validation Errors (400) - ข้อมูล Input ผิดพลาด +export class ValidationException extends BaseException { + constructor(message: string, details?: ValidationErrorDetail[]) { + super( + ErrorType.VALIDATION, + 'VALIDATION_ERROR', + message, + 'ข้อมูลที่กรอกไม่ถูกต้อง กรุณาตรวจสอบและลองใหม่', + ErrorSeverity.LOW, + details, + ['ตรวจสอบข้อมูลที่กรอก', 'แก้ไขข้อมูลที่ผิดพลาด', 'ลองใหม่อีกครั้ง'] + ); + } +} + +// Business Rule Errors (422) - ละเมิดกฎทางธุรกิจ +export class BusinessException extends BaseException { + constructor( + code: string, + message: string, + userMessage?: string, + recoveryActions?: string[] + ) { + super( + ErrorType.BUSINESS_RULE, + code, + message, + userMessage || 'ไม่สามารถดำเนินการได้เนื่องจากเงื่อนไขทางธุรกิจ', + ErrorSeverity.MEDIUM, + undefined, + recoveryActions || ['ติดต่อผู้ดูแลระบบ', 'ตรวจสอบเงื่อนไขการดำเนินการ'] + ); + } +} + +// Not Found Errors (404) - ไม่พบข้อมูล +export class NotFoundException extends BaseException { + constructor(resource: string, identifier?: string) { + super( + ErrorType.NOT_FOUND, + 'NOT_FOUND', + `${resource}${identifier ? ` with identifier "${identifier}"` : ''} not found`, + `ไม่พบ${resource}ที่ค้นหา`, + ErrorSeverity.LOW, + undefined, + ['ตรวจสอบ ID/UUID ที่ระบุ', 'ค้นหาข้อมูลจากรายการ'] + ); + } +} + +// Permission Errors (403) - ไม่มีสิทธิ์ +export class PermissionException extends BaseException { + constructor(resource: string, action: string) { + super( + ErrorType.PERMISSION_DENIED, + 'PERMISSION_DENIED', + `User lacks permission for "${action}" on "${resource}"`, + `คุณไม่มีสิทธิ์ดำเนินการ "${action}" บน "${resource}"`, + ErrorSeverity.MEDIUM, + { resource, action }, + ['ติดต่อผู้ดูแลระบบเพื่อขอสิทธิ์', 'ลองใช้บัญชีที่มีสิทธิ์'] + ); + } +} + +// Conflict Errors (409) - ข้อมูลซ้ำ / ขัดแย้ง +export class ConflictException extends BaseException { + constructor( + code: string, + message: string, + userMessage?: string, + recoveryActions?: string[] + ) { + super( + ErrorType.CONFLICT, + code, + message, + userMessage || 'ข้อมูลซ้ำกันหรือขัดแย้งกับข้อมูลที่มีอยู่', + ErrorSeverity.MEDIUM, + undefined, + recoveryActions || ['ตรวจสอบข้อมูลที่มีอยู่', 'แก้ไขข้อมูลที่ขัดแย้ง'] + ); + } +} + +// Workflow Errors (422) - ข้อผิดพลาดจาก Workflow Engine +export class WorkflowException extends BaseException { + constructor( + code: string, + message: string, + userMessage?: string, + recoveryActions?: string[] + ) { + super( + ErrorType.BUSINESS_RULE, + code, + message, + userMessage || 'ไม่สามารถดำเนินการ Workflow ได้ในสถานะปัจจุบัน', + ErrorSeverity.MEDIUM, + undefined, + recoveryActions || ['ตรวจสอบสถานะเอกสาร', 'ดำเนินการอื่นที่อนุญาต'] + ); + } +} + +// System/Infrastructure Errors (500) - ปัญหาระบบ +export class SystemException extends BaseException { + constructor(message: string, details?: Record) { + super( + ErrorType.INTERNAL_ERROR, + 'INTERNAL_ERROR', + message, + 'เกิดข้อผิดพลาดในระบบ กรุณาลองใหม่ภายหลัง', + ErrorSeverity.HIGH, + details, + ['ลองใหม่อีกครั้ง', 'ติดต่อผู้ดูแลระบบหากยังพบปัญหา'] + ); + } +} + +// Database Errors (500) - ปัญหาฐานข้อมูล +export class DatabaseException extends BaseException { + constructor(message: string, details?: Record) { + super( + ErrorType.DATABASE_ERROR, + 'DATABASE_ERROR', + message, + 'เกิดข้อผิดพลาดของฐานข้อมูล กรุณาลองใหม่ภายหลัง', + ErrorSeverity.HIGH, + details, + ['ลองใหม่ภายหลัง', 'แจ้งผู้ดูแลระบบหากยังพบปัญหา'] + ); + } +} diff --git a/backend/src/common/exceptions/index.ts b/backend/src/common/exceptions/index.ts new file mode 100644 index 0000000..9ac0401 --- /dev/null +++ b/backend/src/common/exceptions/index.ts @@ -0,0 +1,19 @@ +// File: src/common/exceptions/index.ts +// Barrel export สำหรับ Exception Hierarchy ทั้งหมด + +export { + ErrorType, + ErrorSeverity, + getStatusCode, + BaseException, + ValidationException, + BusinessException, + NotFoundException, + PermissionException, + ConflictException, + WorkflowException, + SystemException, + DatabaseException, +} from './base.exception'; + +export type { ValidationErrorDetail, ErrorPayload } from './base.exception'; diff --git a/backend/src/common/filters/global-exception.filter.ts b/backend/src/common/filters/global-exception.filter.ts new file mode 100644 index 0000000..dce30f0 --- /dev/null +++ b/backend/src/common/filters/global-exception.filter.ts @@ -0,0 +1,216 @@ +// File: src/common/filters/global-exception.filter.ts +// ADR-007: Global Exception Filter พร้อม Layered Error Processing + +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Injectable, + Logger, +} from '@nestjs/common'; +import { Response } from 'express'; +import { RequestWithUser } from '../interfaces/request-with-user.interface'; +import { + BaseException, + ErrorType, + ErrorSeverity, + ErrorPayload, +} from '../exceptions/base.exception'; + +// รูปแบบ Error Response ที่ส่งกลับ Client +interface ErrorResponse { + error: ErrorPayload & { statusCode: number }; +} + +// ข้อมูล Log สำหรับ Error +interface ErrorLogData { + path: string; + method: string; + userId?: number; + ip: string; + userAgent: string; + exception: { + name: string; + message: string; + stack?: string; + details?: unknown; + }; +} + +@Injectable() +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(GlobalExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let errorResponse: ErrorResponse; + let httpStatus: number; + + if (exception instanceof BaseException) { + // จัดการ Custom Exception ของเรา (ADR-007) + const payload = exception.getResponse() as { error: ErrorPayload }; + httpStatus = exception.httpStatus; + errorResponse = { + error: { + ...payload.error, + statusCode: httpStatus, + }, + }; + this.logError( + exception, + request, + exception.severity === ErrorSeverity.CRITICAL + ); + } else if (exception instanceof HttpException) { + // จัดการ NestJS Built-in Exceptions + httpStatus = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + // แปลง NestJS exception response เป็น user-friendly message + let technicalDetail: unknown = exceptionResponse; + if (typeof exceptionResponse === 'object' && exceptionResponse !== null) { + technicalDetail = exceptionResponse; + } + + errorResponse = { + error: { + type: this.mapStatusToErrorType(httpStatus), + code: 'HTTP_ERROR', + message: this.mapStatusToUserMessage(httpStatus), + severity: + httpStatus >= 500 ? ErrorSeverity.HIGH : ErrorSeverity.MEDIUM, + timestamp: new Date().toISOString(), + statusCode: httpStatus, + recoveryActions: this.mapStatusToRecoveryActions(httpStatus), + ...(process.env['NODE_ENV'] !== 'production' && { + technicalMessage: exception.message, + details: technicalDetail as Record, + }), + }, + }; + this.logError(exception, request, httpStatus >= 500); + } else { + // จัดการ Unexpected Errors (ไม่รู้ประเภท) + httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + errorResponse = { + error: { + type: ErrorType.INTERNAL_ERROR, + code: 'UNEXPECTED_ERROR', + message: 'เกิดข้อผิดพลาดที่ไม่คาดคิด กรุณาลองใหม่ภายหลัง', + severity: ErrorSeverity.CRITICAL, + timestamp: new Date().toISOString(), + statusCode: httpStatus, + recoveryActions: [ + 'ลองใหม่อีกครั้ง', + 'ติดต่อผู้ดูแลระบบหากยังพบปัญหา', + ], + }, + }; + this.logError(exception, request, true); + } + + response.status(httpStatus).json(errorResponse); + } + + // Logging แยกตามความรุนแรง + private logError( + exception: unknown, + request: RequestWithUser, + isCritical: boolean + ): void { + const err = + exception instanceof Error ? exception : new Error(String(exception)); + + const logData: ErrorLogData = { + path: request.url, + method: request.method, + userId: request.user?.user_id, + ip: request.ip ?? 'unknown', + userAgent: (request.headers['user-agent'] as string) ?? 'unknown', + exception: { + name: err.name, + message: err.message, + stack: err.stack, + details: + exception instanceof BaseException ? exception.details : undefined, + }, + }; + + if (isCritical) { + this.logger.error('Critical error occurred', JSON.stringify(logData)); + } else { + this.logger.warn('Error occurred', JSON.stringify(logData)); + } + } + + // แปลง HTTP Status เป็น ErrorType + private mapStatusToErrorType(status: number): ErrorType { + switch (status) { + case 400: + return ErrorType.VALIDATION; + case 401: + case 403: + return ErrorType.PERMISSION_DENIED; + case 404: + return ErrorType.NOT_FOUND; + case 409: + return ErrorType.CONFLICT; + case 422: + return ErrorType.BUSINESS_RULE; + default: + return ErrorType.INTERNAL_ERROR; + } + } + + // แปลง HTTP Status เป็น User-friendly Message (ภาษาไทย) + private mapStatusToUserMessage(status: number): string { + switch (status) { + case 400: + return 'ข้อมูลที่ส่งมาไม่ถูกต้อง กรุณาตรวจสอบและลองใหม่'; + case 401: + return 'กรุณาเข้าสู่ระบบก่อนใช้งาน'; + case 403: + return 'คุณไม่มีสิทธิ์ในการดำเนินการนี้'; + case 404: + return 'ไม่พบข้อมูลที่ร้องขอ'; + case 409: + return 'ข้อมูลซ้ำกันหรือมีความขัดแย้ง'; + case 422: + return 'ไม่สามารถดำเนินการได้เนื่องจากเงื่อนไขทางธุรกิจ'; + case 429: + return 'คำขอมากเกินไป กรุณารอสักครู่แล้วลองใหม่'; + default: + return 'เกิดข้อผิดพลาดในระบบ กรุณาลองใหม่ภายหลัง'; + } + } + + // Recovery Actions สำหรับแต่ละ HTTP Status + private mapStatusToRecoveryActions(status: number): string[] { + switch (status) { + case 400: + return [ + 'ตรวจสอบข้อมูลที่กรอก', + 'แก้ไขข้อมูลที่ผิดพลาด', + 'ลองใหม่อีกครั้ง', + ]; + case 401: + return ['เข้าสู่ระบบ', 'ตรวจสอบ Session ที่หมดอายุ']; + case 403: + return ['ติดต่อผู้ดูแลระบบเพื่อขอสิทธิ์', 'ลองใช้บัญชีที่มีสิทธิ์']; + case 404: + return ['ตรวจสอบ ID/UUID ที่ระบุ', 'ค้นหาข้อมูลจากรายการ']; + case 409: + return ['ตรวจสอบข้อมูลที่มีอยู่', 'แก้ไขข้อมูลที่ขัดแย้ง']; + case 429: + return ['รอสักครู่แล้วลองใหม่']; + default: + return ['ลองใหม่อีกครั้ง', 'ติดต่อผู้ดูแลระบบหากยังพบปัญหา']; + } + } +} diff --git a/backend/src/modules/circulation/circulation.service.ts b/backend/src/modules/circulation/circulation.service.ts index c935045..89bbd03 100644 --- a/backend/src/modules/circulation/circulation.service.ts +++ b/backend/src/modules/circulation/circulation.service.ts @@ -1,9 +1,9 @@ +import { Injectable } from '@nestjs/common'; import { - Injectable, NotFoundException, - BadRequestException, - ForbiddenException, -} from '@nestjs/common'; + PermissionException, + ValidationException, +} from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; @@ -53,15 +53,18 @@ export class CirculationService { user.user_id ); if (!canManageAll) { - throw new ForbiddenException( - 'You do not have permission to create documents on behalf of other organizations.' + throw new PermissionException( + 'circulation', + 'create on behalf of other organization' ); } userOrgId = resolvedOriginatorId; } if (!userOrgId) { - throw new BadRequestException('User must belong to an organization'); + throw new ValidationException( + 'User must belong to an organization to create a circulation' + ); } const queryRunner = this.dataSource.createQueryRunner(); @@ -195,11 +198,12 @@ export class CirculationService { relations: ['circulation'], }); - if (!routing) throw new NotFoundException('Routing task not found'); + if (!routing) + throw new NotFoundException('Routing task', String(routingId)); // Check Permission: คนทำต้องเป็นเจ้าของ Task if (routing.assignedTo !== user.user_id) { - throw new ForbiddenException('You are not assigned to this task'); + throw new PermissionException('circulation routing task', 'process'); } // Update Routing diff --git a/backend/src/modules/correspondence/correspondence.service.spec.ts b/backend/src/modules/correspondence/correspondence.service.spec.ts index acb90cb..87fc610 100644 --- a/backend/src/modules/correspondence/correspondence.service.spec.ts +++ b/backend/src/modules/correspondence/correspondence.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; -import { ForbiddenException } from '@nestjs/common'; +import { PermissionException } from '../../common/exceptions'; import { CorrespondenceService } from './correspondence.service'; import { Correspondence } from './entities/correspondence.entity'; import { CorrespondenceRevision } from './entities/correspondence-revision.entity'; @@ -260,7 +260,7 @@ describe('CorrespondenceService', () => { await expect( service.update(2, { subject: 'Should Fail' }, mockUser) - ).rejects.toThrow(ForbiddenException); + ).rejects.toThrow(PermissionException); }); it('should NOT regenerate number if critical fields unchanged', async () => { diff --git a/backend/src/modules/correspondence/correspondence.service.ts b/backend/src/modules/correspondence/correspondence.service.ts index 240e36d..6066803 100644 --- a/backend/src/modules/correspondence/correspondence.service.ts +++ b/backend/src/modules/correspondence/correspondence.service.ts @@ -1,13 +1,13 @@ // File: src/modules/correspondence/correspondence.service.ts +import { Injectable, Logger } from '@nestjs/common'; import { - Injectable, + BusinessException, NotFoundException, - BadRequestException, - InternalServerErrorException, - ForbiddenException, - Logger, -} from '@nestjs/common'; + PermissionException, + SystemException, + ValidationException, +} from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; @@ -125,7 +125,7 @@ export class CorrespondenceService { } if (!userOrgId) { - throw new BadRequestException( + throw new ValidationException( 'User must belong to an organization to create documents' ); } @@ -139,14 +139,17 @@ export class CorrespondenceService { // Check if it's internal communication if (createDto.isInternal) { // Internal communications should use Circulation instead - throw new BadRequestException( - 'Internal communications should use Circulation Sheet instead of Correspondence' + throw new BusinessException( + 'INVALID_DOCUMENT_TYPE', + 'Internal communications should use Circulation Sheet instead of Correspondence', + 'การสื่อสารภายในควรใช้ Circulation Sheet แทน Correspondence', + ['ใช้ Circulation Sheet สำหรับการสื่อสารภายในองค์กร'] ); } // Validate recipients if (!createDto.recipients || createDto.recipients.length === 0) { - throw new BadRequestException( + throw new ValidationException( 'At least one recipient (TO or CC) is required' ); } @@ -155,7 +158,7 @@ export class CorrespondenceService { const ccRecipients = createDto.recipients.filter((r) => r.type === 'CC'); if (toRecipients.length === 0 && ccRecipients.length === 0) { - throw new BadRequestException( + throw new ValidationException( 'At least one TO or CC recipient is required' ); } @@ -167,8 +170,11 @@ export class CorrespondenceService { ); if (recipientOrgId === originatorOrgId) { - throw new BadRequestException( - 'Cannot send correspondence to your own organization. Use Circulation Sheet for internal communication.' + throw new BusinessException( + 'CORRESPONDENCE_TO_SELF', + 'Cannot send correspondence to your own organization', + 'ไม่สามารถส่งเอกสารถึงองค์กรของตัวเองได้ ใช้ Circulation Sheet แทน', + ['ใช้ Circulation Sheet สำหรับการสื่อสารภายใน'] ); } } @@ -199,15 +205,14 @@ export class CorrespondenceService { const type = await this.typeRepo.findOne({ where: { id: createDto.typeId }, }); - if (!type) throw new NotFoundException('Document Type not found'); + if (!type) + throw new NotFoundException('Document Type', String(createDto.typeId)); const statusDraft = await this.statusRepo.findOne({ where: { statusCode: 'DRAFT' }, }); if (!statusDraft) { - throw new InternalServerErrorException( - 'Status DRAFT not found in Master Data' - ); + throw new SystemException('Status DRAFT not found in Master Data'); } let userOrgId = user.primaryOrganizationId; @@ -225,15 +230,16 @@ export class CorrespondenceService { user.user_id ); if (!canManageAll) { - throw new ForbiddenException( - 'You do not have permission to create documents on behalf of other organizations.' + throw new PermissionException( + 'correspondence', + 'create on behalf of other organization' ); } userOrgId = resolvedOriginatorId; } if (!userOrgId) { - throw new BadRequestException( + throw new ValidationException( 'User must belong to an organization to create documents' ); } @@ -505,7 +511,7 @@ export class CorrespondenceService { }); if (!correspondence) { - throw new NotFoundException(`Correspondence with ID ${id} not found`); + throw new NotFoundException('Correspondence', String(id)); } return correspondence; } @@ -533,9 +539,7 @@ export class CorrespondenceService { .getOne(); if (!correspondence) { - throw new NotFoundException( - `Correspondence with UUID ${publicId} not found` - ); + throw new NotFoundException('Correspondence', publicId); } return correspondence; } @@ -548,11 +552,15 @@ export class CorrespondenceService { }); if (!source || !target) { - throw new NotFoundException('Source or Target correspondence not found'); + throw new NotFoundException('Source or Target correspondence'); } if (source.id === target.id) { - throw new BadRequestException('Cannot reference self'); + throw new BusinessException( + 'SELF_REFERENCE', + 'Cannot reference self', + 'ไม่สามารถอ้างอิงเอกสารเดียวกันได้' + ); } const exists = await this.referenceRepo.findOne({ @@ -581,7 +589,7 @@ export class CorrespondenceService { }); if (result.affected === 0) { - throw new NotFoundException('Reference not found'); + throw new NotFoundException('Reference'); } } @@ -598,14 +606,14 @@ export class CorrespondenceService { where: { id }, }); if (!correspondence) { - throw new NotFoundException(`Correspondence ${id} not found`); + throw new NotFoundException('Correspondence', String(id)); } const tag = await this.dataSource.manager.findOne(Tag, { where: { id: tagId }, }); if (!tag) { - throw new NotFoundException(`Tag ${tagId} not found`); + throw new NotFoundException('Tag', String(tagId)); } const exists = await this.tagRepo.findOne({ @@ -620,7 +628,7 @@ export class CorrespondenceService { async removeTag(id: number, tagId: number) { const result = await this.tagRepo.delete({ correspondenceId: id, tagId }); if (result.affected === 0) { - throw new NotFoundException('Tag assignment not found'); + throw new NotFoundException('Tag assignment'); } } @@ -649,9 +657,7 @@ export class CorrespondenceService { }); if (!revision) { - throw new NotFoundException( - `Current revision for correspondence ${id} not found` - ); + throw new NotFoundException('Current revision', `correspondence:${id}`); } // 2. Check Permission @@ -669,9 +675,7 @@ export class CorrespondenceService { permissions.includes('system.manage_all'); if (!canEditSubmittedOrLater) { - throw new ForbiddenException( - 'Only Org Admin or Superadmin can edit non-draft correspondences' - ); + throw new PermissionException('correspondence', 'edit non-draft'); } } } @@ -699,7 +703,7 @@ export class CorrespondenceService { // 3. Check if number regeneration is needed (only for DRAFT status) const oldCorr = revision.correspondence; if (!oldCorr) { - throw new InternalServerErrorException( + throw new SystemException( 'Correspondence relation not loaded for revision' ); } @@ -734,7 +738,7 @@ export class CorrespondenceService { const type = await this.typeRepo.findOne({ where: { id: typeId } }); if (!type) { - throw new NotFoundException('Document Type not found'); + throw new NotFoundException('Document Type', String(typeId)); } // Get recipient org code for number generation @@ -898,7 +902,8 @@ export class CorrespondenceService { const type = await this.typeRepo.findOne({ where: { id: createDto.typeId }, }); - if (!type) throw new NotFoundException('Document Type not found'); + if (!type) + throw new NotFoundException('Document Type', String(createDto.typeId)); let userOrgId = user.primaryOrganizationId; if (!userOrgId) { @@ -953,9 +958,7 @@ export class CorrespondenceService { permissions.includes('system.manage_all'); if (!canCancel) { - throw new ForbiddenException( - 'Only administrators can cancel correspondences' - ); + throw new PermissionException('correspondence', 'cancel'); } // Check if there are any active circulations @@ -981,7 +984,7 @@ export class CorrespondenceService { }); if (!currentRevision) { - throw new NotFoundException('Current revision not found'); + throw new NotFoundException('Current revision'); } // Get cancelled status @@ -990,7 +993,7 @@ export class CorrespondenceService { }); if (!cancelledStatus) { - throw new InternalServerErrorException('CANCELLED status not found'); + throw new SystemException('CANCELLED status not found in Master Data'); } const queryRunner = this.dataSource.createQueryRunner(); diff --git a/backend/src/modules/dashboard/dashboard.service.ts b/backend/src/modules/dashboard/dashboard.service.ts index 6478f15..903874f 100644 --- a/backend/src/modules/dashboard/dashboard.service.ts +++ b/backend/src/modules/dashboard/dashboard.service.ts @@ -24,7 +24,10 @@ import { } from './dto'; import { Project } from '../project/entities/project.entity'; import { UserAssignment } from '../user/entities/user-assignment.entity'; -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { + NotFoundException, + PermissionException, +} from '../../common/exceptions'; @Injectable() export class DashboardService { @@ -58,7 +61,7 @@ export class DashboardService { }); if (!project) { - throw new NotFoundException(`Project with ID ${projectId} not found`); + throw new NotFoundException('Project', String(projectId)); } // 2. ตรวจสอบสิทธิ (UserAssignment) @@ -82,9 +85,7 @@ export class DashboardService { this.logger.warn( `User ${userId} attempted to access project ${projectId} without assignment` ); - throw new ForbiddenException( - `You do not have access to project ${projectId}` - ); + throw new PermissionException('project', 'view'); } return project.id; diff --git a/backend/src/modules/document-numbering/services/document-numbering.service.ts b/backend/src/modules/document-numbering/services/document-numbering.service.ts index 8e352ce..94e1281 100644 --- a/backend/src/modules/document-numbering/services/document-numbering.service.ts +++ b/backend/src/modules/document-numbering/services/document-numbering.service.ts @@ -1,4 +1,5 @@ -import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { BusinessException } from '../../../common/exceptions'; import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm'; import { Repository, EntityManager, IsNull, Equal } from 'typeorm'; import { ConfigService } from '@nestjs/config'; @@ -318,8 +319,11 @@ export class DocumentNumberingService { async setCounterValue(id: number, sequence: number) { await Promise.resolve(id); // satisfy unused await Promise.resolve(sequence); - throw new BadRequestException( - 'Updating counter by single ID is not supported with composite keys. Use manualOverride.' + throw new BusinessException( + 'COUNTER_UPDATE_NOT_SUPPORTED', + 'Updating counter by single ID is not supported with composite keys', + 'ไม่รองรับการอัปเดต Counter แบบ Single ID กรุณาใช้ manualOverride', + ['ใช้ manualOverride แทน'] ); } diff --git a/backend/src/modules/json-schema/json-schema.service.ts b/backend/src/modules/json-schema/json-schema.service.ts index 390276d..66f8f57 100644 --- a/backend/src/modules/json-schema/json-schema.service.ts +++ b/backend/src/modules/json-schema/json-schema.service.ts @@ -1,13 +1,12 @@ // File: src/modules/json-schema/json-schema.service.ts // บันทึกการแก้ไข: Fix TS2345 (undefined check) +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { - BadRequestException, - Injectable, - Logger, + BusinessException, NotFoundException, - OnModuleInit, -} from '@nestjs/common'; + ValidationException, +} from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import Ajv, { ValidateFunction } from 'ajv'; import addFormats from 'ajv-formats'; @@ -101,7 +100,7 @@ export class JsonSchemaService implements OnModuleInit { try { this.ajv.compile(createDto.schemaDefinition); } catch (error: unknown) { - throw new BadRequestException( + throw new ValidationException( `Invalid JSON Schema format: ${error instanceof Error ? error.message : String(error)}` ); } @@ -207,7 +206,7 @@ export class JsonSchemaService implements OnModuleInit { async findOne(id: number): Promise { const schema = await this.jsonSchemaRepository.findOne({ where: { id } }); if (!schema) { - throw new NotFoundException(`JsonSchema with ID ${id} not found`); + throw new NotFoundException('JsonSchema', String(id)); } return schema; } @@ -224,9 +223,7 @@ export class JsonSchemaService implements OnModuleInit { }); if (!schema) { - throw new NotFoundException( - `JsonSchema '${code}' version ${version} not found` - ); + throw new NotFoundException('JsonSchema', `${code}@v${version}`); } return schema; } @@ -241,9 +238,7 @@ export class JsonSchemaService implements OnModuleInit { }); if (!schema) { - throw new NotFoundException( - `Active JsonSchema with code '${code}' not found` - ); + throw new NotFoundException('Active JsonSchema', code); } return schema; } @@ -333,8 +328,10 @@ export class JsonSchemaService implements OnModuleInit { validate = this.ajv.compile(schema.schemaDefinition); this.validators.set(schemaCode, validate); } catch (error: unknown) { - throw new BadRequestException( - `Invalid Schema Definition for '${schemaCode}': ${error instanceof Error ? error.message : String(error)}` + throw new BusinessException( + 'INVALID_SCHEMA_DEFINITION', + `Invalid Schema Definition for '${schemaCode}': ${error instanceof Error ? error.message : String(error)}`, + 'Schema Definition ไม่ถูกต้อง' ); } } @@ -353,7 +350,7 @@ export class JsonSchemaService implements OnModuleInit { const errorMsg = result.errors .map((e) => `${e.field}: ${e.message}`) .join(', '); - throw new BadRequestException(`JSON Validation Failed: ${errorMsg}`); + throw new ValidationException(`JSON Validation Failed: ${errorMsg}`); } return true; } @@ -372,7 +369,7 @@ export class JsonSchemaService implements OnModuleInit { try { this.ajv.compile(updateDto.schemaDefinition); } catch (error: unknown) { - throw new BadRequestException( + throw new ValidationException( `Invalid JSON Schema format: ${error instanceof Error ? error.message : String(error)}` ); } diff --git a/backend/src/modules/json-schema/services/schema-migration.service.ts b/backend/src/modules/json-schema/services/schema-migration.service.ts index 1e0f715..24881d5 100644 --- a/backend/src/modules/json-schema/services/schema-migration.service.ts +++ b/backend/src/modules/json-schema/services/schema-migration.service.ts @@ -1,5 +1,9 @@ // File: src/modules/json-schema/services/schema-migration.service.ts -import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { + BusinessException, + NotFoundException, +} from '../../../common/exceptions'; import { DataSource } from 'typeorm'; import { JsonSchemaService } from '../json-schema.service'; @@ -66,9 +70,7 @@ export class SchemaMigrationService { ]); if (!entities || entities.length === 0) { - throw new BadRequestException( - `Entity ${entityType} with ID ${entityId} not found.` - ); + throw new NotFoundException(entityType, String(entityId)); } const entity = entities[0]; @@ -125,8 +127,10 @@ export class SchemaMigrationService { ); if (!validation.isValid) { - throw new BadRequestException( - `Migration failed: Resulting data does not match target schema v${targetSchema.version}. Errors: ${JSON.stringify(validation.errors)}` + throw new BusinessException( + 'SCHEMA_MIGRATION_VALIDATION_FAILED', + `Migration failed: Data does not match target schema v${targetSchema.version}`, + 'การ Migration ล้มเหลว: ข้อมูลไม่ตรงกับ Schema เป้าหมาย' ); } diff --git a/backend/src/modules/json-schema/services/ui-schema.service.ts b/backend/src/modules/json-schema/services/ui-schema.service.ts index 8f2f1bc..ab3a612 100644 --- a/backend/src/modules/json-schema/services/ui-schema.service.ts +++ b/backend/src/modules/json-schema/services/ui-schema.service.ts @@ -1,5 +1,6 @@ // File: src/modules/json-schema/services/ui-schema.service.ts -import { Injectable, BadRequestException, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { ValidationException } from '../../../common/exceptions'; import { UiSchema, UiSchemaField, @@ -21,8 +22,8 @@ export class UiSchemaService { // 1. Validate Structure เบื้องต้น if (!uiSchema.layout || !uiSchema.fields) { - throw new BadRequestException( - 'UI Schema must contain "layout" and "fields" properties.' + throw new ValidationException( + 'UI Schema must contain "layout" and "fields" properties' ); } @@ -34,7 +35,7 @@ export class UiSchemaService { group.fields.forEach((fieldKey) => { layoutFields.add(fieldKey); if (!definedFields.has(fieldKey)) { - throw new BadRequestException( + throw new ValidationException( `Field "${fieldKey}" used in layout "${group.title}" is not defined in "fields".` ); } diff --git a/backend/src/modules/migration/migration.service.ts b/backend/src/modules/migration/migration.service.ts index aa77c0c..2aa1b4a 100644 --- a/backend/src/modules/migration/migration.service.ts +++ b/backend/src/modules/migration/migration.service.ts @@ -1,10 +1,11 @@ +import { Injectable, Logger } from '@nestjs/common'; import { - Injectable, - Logger, + BusinessException, ConflictException, - BadRequestException, - InternalServerErrorException, -} from '@nestjs/common'; + NotFoundException, + SystemException, + ValidationException, +} from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { ImportCorrespondenceDto } from './dto/import-correspondence.dto'; @@ -57,7 +58,7 @@ export class MigrationService { userId: number ) { if (!idempotencyKey) { - throw new BadRequestException('Idempotency-Key header is required'); + throw new ValidationException('Idempotency-Key header is required'); } // 1. Idempotency Check @@ -76,7 +77,10 @@ export class MigrationService { }; } else { throw new ConflictException( - `Transaction failed previously with status ${existingTransaction.statusCode}` + 'MIGRATION_DUPLICATE_TRANSACTION', + `Transaction failed previously with status ${existingTransaction.statusCode}`, + 'รายการนี้เคยดำเนินการไปแล้วและล้มเหลว', + ['ตรวจสอบสถานะ Transaction ก่อนหน้า', 'ลองใช้ Idempotency-Key ใหม่'] ); } } @@ -114,8 +118,8 @@ export class MigrationService { } if (!typeId) { - throw new BadRequestException( - `Category "${dto.category}" not found in system.` + throw new ValidationException( + `Category "${dto.category}" not found in system` ); } @@ -129,8 +133,8 @@ export class MigrationService { }); } if (!status) { - throw new InternalServerErrorException( - 'CRITICAL: No default correspondence status found (missing CLBOWN/DRAFT)' + throw new SystemException( + 'No default correspondence status found (missing CLBOWN/DRAFT)' ); } @@ -139,9 +143,7 @@ export class MigrationService { where: { id: dto.projectId }, }); if (!project) { - throw new BadRequestException( - `Project ID ${dto.projectId} not found in database` - ); + throw new NotFoundException('Project', String(dto.projectId)); } const isRFA = type?.typeCode === 'RFA' || dto.category === 'RFA'; @@ -397,9 +399,7 @@ export class MigrationService { }); await this.importTransactionRepo.save(failedTransaction).catch(() => {}); - throw new InternalServerErrorException( - 'Migration import failed: ' + errorMessage - ); + throw new SystemException('Migration import failed: ' + errorMessage); } finally { await queryRunner.release(); } @@ -407,7 +407,7 @@ export class MigrationService { async enqueueRecord(dto: EnqueueMigrationDto) { if (!dto.documentNumber) { - throw new BadRequestException('documentNumber is required'); + throw new ValidationException('documentNumber is required'); } // Determine status based on confidence policy in ADR-017 @@ -492,7 +492,7 @@ export class MigrationService { async getQueueItemById(id: number) { const item = await this.reviewQueueRepo.findOne({ where: { id } }); if (!item) { - throw new BadRequestException(`Queue item with ID ${id} not found`); + throw new NotFoundException('Queue item', String(id)); } return item; } @@ -538,12 +538,14 @@ export class MigrationService { ) { const queueItem = await this.reviewQueueRepo.findOne({ where: { id } }); if (!queueItem) { - throw new BadRequestException(`Queue item ${id} not found`); + throw new NotFoundException('Queue item', String(id)); } if (queueItem.status !== MigrationReviewStatus.PENDING) { - throw new BadRequestException( - `Queue item ${id} is already ${queueItem.status}` + throw new BusinessException( + 'MIGRATION_ITEM_NOT_PENDING', + `Queue item ${id} is already ${queueItem.status}`, + 'รายการนี้ไม่อยู่ในสถานะ PENDING' ); } @@ -565,7 +567,7 @@ export class MigrationService { userId: number ) { if (!idempotencyKey) { - throw new BadRequestException('Idempotency-Key header is required'); + throw new ValidationException('Idempotency-Key header is required'); } const results = []; @@ -612,7 +614,7 @@ export class MigrationService { async rejectQueueItem(id: number, userId: number) { const queueItem = await this.reviewQueueRepo.findOne({ where: { id } }); if (!queueItem) { - throw new BadRequestException('Queue item not found'); + throw new NotFoundException('Queue item', String(id)); } queueItem.status = MigrationReviewStatus.REJECTED; @@ -628,12 +630,12 @@ export class MigrationService { getStagingFileStream(filePath: string) { if (!filePath) { - throw new BadRequestException('File path is required'); + throw new ValidationException('File path is required'); } const resolvedPath = path.resolve(filePath); if (!existsSync(resolvedPath)) { - throw new BadRequestException('File not found at specified path'); + throw new NotFoundException('File', filePath); } return createReadStream(resolvedPath); diff --git a/backend/src/modules/rfa/rfa.service.ts b/backend/src/modules/rfa/rfa.service.ts index d8599cb..967c509 100644 --- a/backend/src/modules/rfa/rfa.service.ts +++ b/backend/src/modules/rfa/rfa.service.ts @@ -1,13 +1,14 @@ // File: src/modules/rfa/rfa.service.ts +import { Injectable, Logger } from '@nestjs/common'; import { - BadRequestException, - ForbiddenException, - Injectable, - InternalServerErrorException, - Logger, + BusinessException, NotFoundException, -} from '@nestjs/common'; + PermissionException, + SystemException, + ValidationException, + WorkflowException, +} from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, In, Repository } from 'typeorm'; @@ -122,7 +123,8 @@ export class RfaService { const rfaType = await this.rfaTypeRepo.findOne({ where: { id: createDto.rfaTypeId }, }); - if (!rfaType) throw new NotFoundException('RFA Type not found'); + if (!rfaType) + throw new NotFoundException('RFA Type', String(createDto.rfaTypeId)); const rfaTypeCode = rfaType.typeCode.toUpperCase(); const rawShopDrawingRefs = createDto.shopDrawingRevisionIds ?? []; @@ -130,25 +132,25 @@ export class RfaService { if (['DDW', 'SDW'].includes(rfaTypeCode)) { if (rawShopDrawingRefs.length === 0) { - throw new BadRequestException( + throw new ValidationException( 'Selected RFA Type requires at least one Shop Drawing Revision' ); } if (rawAsBuiltDrawingRefs.length > 0) { - throw new BadRequestException( + throw new ValidationException( 'Selected RFA Type cannot reference As-Built Drawing Revisions' ); } } else if (rfaTypeCode === 'ADW') { if (rawAsBuiltDrawingRefs.length === 0) { - throw new BadRequestException( + throw new ValidationException( 'Selected RFA Type requires at least one As-Built Drawing Revision' ); } if (rawShopDrawingRefs.length > 0) { - throw new BadRequestException( + throw new ValidationException( 'Selected RFA Type cannot reference Shop Drawing Revisions' ); } @@ -156,7 +158,7 @@ export class RfaService { rawShopDrawingRefs.length > 0 || rawAsBuiltDrawingRefs.length > 0 ) { - throw new BadRequestException( + throw new ValidationException( 'Selected RFA Type does not support drawing revision items' ); } @@ -185,7 +187,7 @@ export class RfaService { where: { typeCode: 'RFA', isActive: true }, }); if (!correspondenceType) { - throw new InternalServerErrorException( + throw new SystemException( 'Correspondence Type RFA not found in Master Data' ); } @@ -195,8 +197,11 @@ export class RfaService { : rfaType.contractId; if (rfaType.contractId !== internalContractId) { - throw new BadRequestException( - 'Selected RFA Type does not belong to the selected contract' + throw new BusinessException( + 'RFA_TYPE_CONTRACT_MISMATCH', + 'Selected RFA Type does not belong to the selected contract', + 'ประเภท RFA ที่เลือกไม่ตรงกับสัญญาที่ระบุ', + ['เลือกประเภท RFA ที่ตรงกับสัญญา'] ); } @@ -206,12 +211,18 @@ export class RfaService { }); if (!discipline) { - throw new NotFoundException('Discipline not found'); + throw new NotFoundException( + 'Discipline', + String(createDto.disciplineId) + ); } if (discipline.contractId !== internalContractId) { - throw new BadRequestException( - 'Selected Discipline does not belong to the selected contract' + throw new BusinessException( + 'DISCIPLINE_CONTRACT_MISMATCH', + 'Selected Discipline does not belong to the selected contract', + 'Discipline ที่เลือกไม่ตรงกับสัญญาที่ระบุ', + ['เลือก Discipline ที่ตรงกับสัญญา'] ); } } @@ -226,9 +237,7 @@ export class RfaService { where: { statusCode: 'DFT' }, }); if (!statusDraft) { - throw new InternalServerErrorException( - 'Status DFT (Draft) not found in Master Data' - ); + throw new SystemException('Status DFT (Draft) not found in Master Data'); } const resolvedOriginatorId = createDto.originatorId @@ -247,15 +256,18 @@ export class RfaService { user.user_id ); if (!canManageAll) { - throw new ForbiddenException( - 'You do not have permission to create documents on behalf of other organizations.' + throw new PermissionException( + 'rfa', + 'create on behalf of other organization' ); } userOrgId = resolvedOriginatorId; } if (!userOrgId) { - throw new BadRequestException('User must belong to an organization'); + throw new ValidationException( + 'User must belong to an organization to create RFA' + ); } // EC-RFA-001: Check for existing active RFA per Shop Drawing Revision @@ -273,9 +285,11 @@ export class RfaService { .getMany(); if (conflictingItems.length > 0) { - throw new BadRequestException( - '[EC-RFA-001] One or more selected Shop Drawing Revisions already have an active RFA. ' + - 'A Shop Drawing Revision can only be referenced by one active RFA at a time.' + throw new BusinessException( + 'EC_RFA_001_ACTIVE_RFA_EXISTS', + '[EC-RFA-001] One or more selected Shop Drawing Revisions already have an active RFA.', + 'Shop Drawing Revision ที่เลือกมี RFA ที่ยังใช้งานอยู่แล้ว', + ['ตรวจสอบ RFA ที่มีอยู่', 'เลือก Shop Drawing Revision อื่น'] ); } } @@ -315,8 +329,8 @@ export class RfaService { } ); if (!corrStatusDraft) - throw new InternalServerErrorException( - 'Correspondence Status DRAFT not found' + throw new SystemException( + 'Correspondence Status DRAFT not found in Master Data' ); // 1. Create Correspondence Record @@ -385,7 +399,7 @@ export class RfaService { }); if (shopDrawings.length !== shopDrawingRevisionIds.length) { - throw new NotFoundException('Some Shop Drawing Revisions not found'); + throw new NotFoundException('Shop Drawing Revision'); } rfaItems.push( @@ -405,9 +419,7 @@ export class RfaService { }); if (asBuiltDrawings.length !== asBuiltDrawingRevisionIds.length) { - throw new NotFoundException( - 'Some As-Built Drawing Revisions not found' - ); + throw new NotFoundException('As-Built Drawing Revision'); } rfaItems.push( @@ -588,7 +600,7 @@ export class RfaService { select: ['id'], }); if (!correspondence) { - throw new NotFoundException(`RFA with publicId ${publicId} not found`); + throw new NotFoundException('RFA', publicId); } return this.findOne(correspondence.id); } @@ -599,7 +611,7 @@ export class RfaService { select: ['id'], }); if (!correspondence) { - throw new NotFoundException(`RFA with publicId ${publicId} not found`); + throw new NotFoundException('RFA', publicId); } return this.findOne(correspondence.id, true); } @@ -628,7 +640,7 @@ export class RfaService { }); if (!rfa) { - throw new NotFoundException(`RFA ID ${id} not found`); + throw new NotFoundException('RFA', String(id)); } if (rawEntities) { @@ -657,12 +669,17 @@ export class RfaService { (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? []; const currentCorrRev = corrRevisions.find((r) => r.isCurrent); if (!currentCorrRev || !currentCorrRev.rfaRevision) - throw new NotFoundException('Current revision not found'); + throw new NotFoundException('Current revision'); const currentRfaRev = currentCorrRev.rfaRevision; if (currentRfaRev.statusCode.statusCode !== 'DFT') { - throw new BadRequestException('Only DRAFT documents can be submitted'); + throw new WorkflowException( + 'RFA_INVALID_SUBMIT_STATUS', + 'Only DRAFT documents can be submitted', + 'สามารถส่งได้เฉพาะเอกสารสถานะ DRAFT เท่านั้น', + ['ตรวจสอบสถานะเอกสารปัจจุบัน'] + ); } const template = await this.templateRepo.findOne({ @@ -671,7 +688,12 @@ export class RfaService { }); if (!template) { - throw new BadRequestException('Invalid routing template'); + throw new BusinessException( + 'ROUTING_TEMPLATE_NOT_FOUND', + 'Invalid routing template', + 'ไม่พบ Routing Template ที่กำหนด', + ['ตรวจสอบ Routing Template ที่ตั้งค่าไว้'] + ); } // Manual fetch of steps @@ -681,14 +703,19 @@ export class RfaService { }); if (steps.length === 0) { - throw new BadRequestException('Routing template has no steps'); + throw new BusinessException( + 'ROUTING_TEMPLATE_EMPTY', + 'Routing template has no steps', + 'Routing Template ไม่มีขั้นตอนกำหนดไว้', + ['เพิ่ม Step ใน Routing Template'] + ); } const statusForApprove = await this.rfaStatusRepo.findOne({ where: { statusCode: 'FAP' }, }); if (!statusForApprove) - throw new InternalServerErrorException('Status FAP not found'); + throw new SystemException('Status FAP not found in Master Data'); const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); @@ -765,11 +792,14 @@ export class RfaService { }); if (!currentRouting) - throw new BadRequestException('No active workflow step found'); - if (currentRouting.toOrganizationId !== user.primaryOrganizationId) { - throw new ForbiddenException( - 'You are not authorized to process this step' + throw new WorkflowException( + 'NO_ACTIVE_WORKFLOW_STEP', + 'No active workflow step found', + 'ไม่พบขั้นตอน Workflow ที่ยังเปิดอยู่', + ['ตรวจสอบสถานะ Workflow ของเอกสาร'] ); + if (currentRouting.toOrganizationId !== user.primaryOrganizationId) { + throw new PermissionException('rfa workflow step', 'process'); } const template = await this.templateRepo.findOne({ @@ -777,7 +807,10 @@ export class RfaService { // relations: ['steps'], }); - if (!template) throw new InternalServerErrorException('Template not found'); + if (!template) + throw new SystemException( + 'Routing Template not found for workflow processing' + ); // Manual fetch steps const steps = await this.templateStepRepo.find({ @@ -786,7 +819,7 @@ export class RfaService { }); if (steps.length === 0) - throw new InternalServerErrorException('Template steps not found'); + throw new SystemException('Routing Template steps not found'); // Call Engine to calculate next step const result = this.workflowEngine.processAction( @@ -874,13 +907,16 @@ export class RfaService { (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? []; const currentCorrRev = corrRevisions.find((r) => r.isCurrent); if (!currentCorrRev || !currentCorrRev.rfaRevision) - throw new NotFoundException('Current revision not found'); + throw new NotFoundException('Current revision'); const currentRfaRev = currentCorrRev.rfaRevision; if (currentRfaRev.statusCode.statusCode !== 'DFT') { - throw new BadRequestException( - 'Only DRAFT documents can be edited. Submit a new revision for non-draft documents.' + throw new WorkflowException( + 'RFA_EDIT_NON_DRAFT', + 'Only DRAFT documents can be edited', + 'สามารถแก้ไขได้เฉพาะเอกสารสถานะ DRAFT เท่านั้น', + ['ส่งเอกสารเพื่อสร้าง Revision ใหม่สำหรับเอกสารที่ไม่ใช่ DRAFT'] ); } @@ -915,13 +951,16 @@ export class RfaService { (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? []; const currentCorrRev = corrRevisions.find((r) => r.isCurrent); if (!currentCorrRev || !currentCorrRev.rfaRevision) - throw new NotFoundException('Current revision not found'); + throw new NotFoundException('Current revision'); const currentRfaRev = currentCorrRev.rfaRevision; if (currentRfaRev.statusCode.statusCode !== 'DFT') { - throw new BadRequestException( - 'Only DRAFT documents can be cancelled. Contact an Org Admin to cancel submitted documents.' + throw new WorkflowException( + 'RFA_CANCEL_NON_DRAFT', + 'Only DRAFT documents can be cancelled', + 'สามารถยกเลิกได้เฉพาะเอกสารสถานะ DRAFT เท่านั้น', + ['ติดต่อ Org Admin เพื่อยกเลิกเอกสารที่ส่งแล้ว'] ); } @@ -929,7 +968,7 @@ export class RfaService { where: { statusCode: 'CC' }, }); if (!statusCC) - throw new InternalServerErrorException( + throw new SystemException( 'Status CC (Cancelled) not found in Master Data' ); diff --git a/backend/src/modules/transmittal/transmittal.service.ts b/backend/src/modules/transmittal/transmittal.service.ts index e558ba2..4e1275e 100644 --- a/backend/src/modules/transmittal/transmittal.service.ts +++ b/backend/src/modules/transmittal/transmittal.service.ts @@ -1,11 +1,10 @@ +import { Injectable, Logger } from '@nestjs/common'; import { - Injectable, - Logger, NotFoundException, - InternalServerErrorException, - BadRequestException, - ForbiddenException, -} from '@nestjs/common'; + PermissionException, + SystemException, + ValidationException, +} from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { Transmittal } from './entities/transmittal.entity'; @@ -57,13 +56,13 @@ export class TransmittalService { const type = await this.typeRepo.findOne({ where: { typeCode: 'TRN' }, // Adjust code as per Master Data }); - if (!type) throw new NotFoundException('Transmittal Type (TRN) not found'); + if (!type) throw new NotFoundException('Transmittal Type (TRN)'); const statusDraft = await this.statusRepo.findOne({ where: { statusCode: 'DRAFT' }, }); if (!statusDraft) - throw new InternalServerErrorException('Status DRAFT not found'); + throw new SystemException('Status DRAFT not found in Master Data'); const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); @@ -86,15 +85,16 @@ export class TransmittalService { user.user_id ); if (!canManageAll) { - throw new ForbiddenException( - 'You do not have permission to create documents on behalf of other organizations.' + throw new PermissionException( + 'transmittal', + 'create on behalf of other organization' ); } userOrgId = resolvedOriginatorId; } if (!userOrgId) { - throw new BadRequestException( + throw new ValidationException( 'User must belong to an organization to create a transmittal' ); } diff --git a/backend/src/modules/user/user-assignment.service.ts b/backend/src/modules/user/user-assignment.service.ts index 3765e2a..d459f55 100644 --- a/backend/src/modules/user/user-assignment.service.ts +++ b/backend/src/modules/user/user-assignment.service.ts @@ -1,4 +1,5 @@ -import { Injectable, BadRequestException, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { ValidationException } from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { UserAssignment } from './entities/user-assignment.entity'; @@ -22,7 +23,7 @@ export class UserAssignmentService { (v) => v != null ); if (scopes.length > 1) { - throw new BadRequestException( + throw new ValidationException( 'Cannot assign multiple scopes at once. Choose one of Org, Project, or Contract.' ); } @@ -55,7 +56,7 @@ export class UserAssignmentService { // Validation (Scope) const scopes = [organizationId, projectId].filter((v) => v != null); if (scopes.length > 1) { - throw new BadRequestException( + throw new ValidationException( `User ${userId}: Cannot assign multiple scopes.` ); } diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index d20c734..2b63111 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -1,12 +1,8 @@ // File: src/modules/user/user.service.ts // บันทึกการแก้ไข: แก้ไข Error TS1272 โดยใช้ 'import type' สำหรับ Cache interface (T1.3) -import { - Injectable, - NotFoundException, - ConflictException, - Inject, -} from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; +import { NotFoundException, ConflictException } from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; @@ -56,7 +52,12 @@ export class UserService { } catch (error: unknown) { const dbError = error as { code?: string }; if (dbError.code === 'ER_DUP_ENTRY') { - throw new ConflictException('Username or Email already exists'); + throw new ConflictException( + 'USER_DUPLICATE', + 'Username or Email already exists', + 'ชื่อผู้ใช้หรืออีเมลนี้มีอยู่ในระบบแล้ว', + ['ลองใช้ชื่อผู้ใช้หรืออีเมลอื่น'] + ); } throw error; } @@ -152,7 +153,7 @@ export class UserService { }); if (!user) { - throw new NotFoundException(`User with ID ${id} not found`); + throw new NotFoundException('User', String(id)); } return user; @@ -171,7 +172,7 @@ export class UserService { }); if (!user) { - throw new NotFoundException(`User with publicId ${publicId} not found`); + throw new NotFoundException('User', publicId); } return user; @@ -217,7 +218,7 @@ export class UserService { const result = await this.usersRepository.softDelete(user.user_id); if (result.affected === 0) { - throw new NotFoundException(`User with UUID ${uuid} not found`); + throw new NotFoundException('User', uuid); } // เคลียร์ Cache เมื่อลบ await this.clearUserCache(user.user_id); @@ -275,7 +276,7 @@ export class UserService { }); if (!role) { - throw new NotFoundException(`Role ID ${roleId} not found`); + throw new NotFoundException('Role', String(roleId)); } // Load permissions entities diff --git a/backend/src/modules/workflow-engine/dsl/parser.service.ts b/backend/src/modules/workflow-engine/dsl/parser.service.ts index 22cba4c..ebcff9c 100644 --- a/backend/src/modules/workflow-engine/dsl/parser.service.ts +++ b/backend/src/modules/workflow-engine/dsl/parser.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { + NotFoundException, + ValidationException, + WorkflowException, +} from '../../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { WorkflowDslSchema, WorkflowDsl } from './workflow-dsl.schema'; @@ -36,16 +41,18 @@ export class WorkflowDslParser { return await this.workflowDefRepo.save(definition); } catch (error: unknown) { if (error instanceof SyntaxError) { - throw new BadRequestException(`Invalid JSON: ${error.message}`); + throw new ValidationException(`Invalid JSON: ${error.message}`); } const err = error as { name?: string; - errors?: unknown; + errors?: unknown[]; message?: string; }; if (err.name === 'ZodError') { - throw new BadRequestException( - `Invalid workflow DSL: ${JSON.stringify(err.errors)}` + throw new WorkflowException( + 'INVALID_WORKFLOW_DSL', + `Invalid workflow DSL: ${JSON.stringify(err.errors)}`, + 'Workflow DSL ไม่ถูกต้อง' ); } throw error; @@ -66,16 +73,20 @@ export class WorkflowDslParser { // 1. Validate initial state if (!stateSet.has(dsl.initialState)) { - throw new BadRequestException( - `Initial state "${dsl.initialState}" not found in states array` + throw new WorkflowException( + 'DSL_INVALID_INITIAL_STATE', + `Initial state "${dsl.initialState}" not found in states array`, + 'Initial State ไม่พบใน States Array' ); } // 2. Validate final states dsl.finalStates.forEach((state) => { if (!stateSet.has(state)) { - throw new BadRequestException( - `Final state "${state}" not found in states array` + throw new WorkflowException( + 'DSL_INVALID_FINAL_STATE', + `Final state "${state}" not found in states array`, + 'Final State ไม่พบใน States Array' ); } }); @@ -86,15 +97,19 @@ export class WorkflowDslParser { dsl.transitions.forEach((transition, index) => { // Check 'from' state if (!stateSet.has(transition.from)) { - throw new BadRequestException( - `Transition ${index}: 'from' state "${transition.from}" not found in states array` + throw new WorkflowException( + 'DSL_INVALID_TRANSITION_FROM', + `Transition ${index}: 'from' state "${transition.from}" not found in states array`, + 'Transition อ้างอิง State ที่ไม่พบ' ); } // Check 'to' state if (!stateSet.has(transition.to)) { - throw new BadRequestException( - `Transition ${index}: 'to' state "${transition.to}" not found in states array` + throw new WorkflowException( + 'DSL_INVALID_TRANSITION_TO', + `Transition ${index}: 'to' state "${transition.to}" not found in states array`, + 'Transition อ้างอิง State ที่ไม่พบ' ); } @@ -120,8 +135,10 @@ export class WorkflowDslParser { dsl.transitions.forEach((transition) => { const key = `${transition.from}-${transition.trigger}-${transition.to}`; if (transitionKeys.has(key)) { - throw new BadRequestException( - `Duplicate transition: ${transition.from} --[${transition.trigger}]--> ${transition.to}` + throw new WorkflowException( + 'DSL_DUPLICATE_TRANSITION', + `Duplicate transition: ${transition.from} --[${transition.trigger}]--> ${transition.to}`, + 'DSL มี Transition ซ้ำซ้อน' ); } transitionKeys.add(key); @@ -158,9 +175,7 @@ export class WorkflowDslParser { }); if (!definition) { - throw new BadRequestException( - `Workflow definition ${definitionId} not found` - ); + throw new NotFoundException('Workflow definition', String(definitionId)); } try { @@ -171,8 +186,10 @@ export class WorkflowDslParser { `Failed to parse stored DSL for definition ${definitionId}`, error ); - throw new BadRequestException( - `Invalid stored DSL: ${error instanceof Error ? error.message : String(error)}` + throw new WorkflowException( + 'INVALID_STORED_DSL', + `Invalid stored DSL: ${error instanceof Error ? error.message : String(error)}`, + 'DSL ที่บันทึกไว้ไม่ถูกต้อง' ); } } diff --git a/backend/src/modules/workflow-engine/workflow-dsl.service.ts b/backend/src/modules/workflow-engine/workflow-dsl.service.ts index 80f6217..5d04858 100644 --- a/backend/src/modules/workflow-engine/workflow-dsl.service.ts +++ b/backend/src/modules/workflow-engine/workflow-dsl.service.ts @@ -1,6 +1,10 @@ // File: src/modules/workflow-engine/workflow-dsl.service.ts -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { + ValidationException, + WorkflowException, +} from '../../common/exceptions'; // ========================================== // 1. Interfaces for RAW DSL (Input from User) @@ -86,8 +90,11 @@ export class WorkflowDslService { for (const rawState of dsl.states) { if (rawState.initial) { if (initialFound) { - throw new BadRequestException( - `DSL Error: Multiple initial states found (at "${rawState.name}").` + throw new WorkflowException( + 'DSL_MULTIPLE_INITIAL_STATES', + `DSL Error: Multiple initial states found (at "${rawState.name}")`, + 'DSL มี Initial State หลายค่า แต่ละ Workflow ต้องมีเพียง Initial State เดียว', + ['ตรวจสอบโครงสร้าง DSL และแก้ไข Initial State'] ); } compiled.initialState = rawState.name; @@ -104,8 +111,11 @@ export class WorkflowDslService { for (const [action, rule] of Object.entries(rawState.on)) { // Validation: Target state must exist if (!definedStates.has(rule.to)) { - throw new BadRequestException( - `DSL Error: State "${rawState.name}" transitions via "${action}" to unknown state "${rule.to}".` + throw new WorkflowException( + 'DSL_UNKNOWN_TRANSITION_TARGET', + `DSL Error: State "${rawState.name}" transitions via "${action}" to unknown state "${rule.to}"`, + 'DSL อ้างอิง State ที่ไม่พบ', + ['ตรวจสอบชื่อ State ที่กำหนดใน Transition'] ); } @@ -133,7 +143,12 @@ export class WorkflowDslService { } if (!initialFound) { - throw new BadRequestException('DSL Error: No initial state defined.'); + throw new WorkflowException( + 'DSL_NO_INITIAL_STATE', + 'DSL Error: No initial state defined', + 'DSL ไม่มีการกำหนด Initial State', + ['เพิ่ม initial: true ใน State หนึ่ง'] + ); } return compiled; @@ -153,15 +168,21 @@ export class WorkflowDslService { // 1. Validate State Existence if (!stateConfig) { - throw new BadRequestException( - `Runtime Error: Current state "${currentState}" is invalid.` + throw new WorkflowException( + 'WORKFLOW_INVALID_CURRENT_STATE', + `Runtime Error: Current state "${currentState}" is invalid`, + 'Workflow อยู่ในสถานะที่ไม่รู้จัก', + ['ตรวจสอบ DSL ของ Workflow'] ); } // 2. Check if terminal if (stateConfig.terminal) { - throw new BadRequestException( - `Runtime Error: Cannot transition from terminal state "${currentState}".` + throw new WorkflowException( + 'WORKFLOW_TERMINAL_STATE', + `Runtime Error: Cannot transition from terminal state "${currentState}"`, + 'ไม่สามารถดำเนินการจาก State สุดท้ายได้', + ['เอกสารสิ้นสุดกระบวนการแล้ว'] ); } @@ -169,8 +190,11 @@ export class WorkflowDslService { const transition = stateConfig.transitions[action]; if (!transition) { const allowed = Object.keys(stateConfig.transitions).join(', '); - throw new BadRequestException( - `Invalid Action: "${action}" is not allowed from "${currentState}". Allowed: [${allowed}]` + throw new WorkflowException( + 'WORKFLOW_INVALID_ACTION', + `Invalid Action: "${action}" is not allowed from "${currentState}". Allowed: [${allowed}]`, + `ไม่สามารถดำเนินการ "${action}" ในสถานะปัจจุบัน ทำได้: [${allowed}]`, + ['เลือกการดำเนินการที่อนุญาตจากรายการ'] ); } @@ -181,8 +205,11 @@ export class WorkflowDslService { if (transition.condition) { const isMet = this.evaluateCondition(transition.condition, context); if (!isMet) { - throw new BadRequestException( - 'Condition Failed: The criteria for this transition are not met.' + throw new WorkflowException( + 'WORKFLOW_CONDITION_NOT_MET', + 'Condition Failed: The criteria for this transition are not met', + 'เงื่อนไขสำหรับการดำเนินการนี้ไม่ผ่าน', + ['ตรวจสอบเงื่อนไขที่กำหนดใน Workflow'] ); } } @@ -199,12 +226,12 @@ export class WorkflowDslService { private validateSchemaStructure(dsl: unknown) { if (!dsl || typeof dsl !== 'object') { - throw new BadRequestException('DSL must be a JSON object.'); + throw new ValidationException('DSL must be a JSON object'); } const d = dsl as Record; if (!d.workflow || !d.states || !Array.isArray(d.states)) { - throw new BadRequestException( - 'DSL Error: Missing required fields (workflow, states).' + throw new ValidationException( + 'DSL Error: Missing required fields (workflow, states)' ); } } @@ -226,15 +253,23 @@ export class WorkflowDslService { if (requiredRoles.length > 0) { const hasRole = requiredRoles.some((r) => userRoles.includes(r)); if (!hasRole) { - throw new BadRequestException( - `Access Denied: Required roles [${requiredRoles.join(', ')}]` + throw new WorkflowException( + 'WORKFLOW_ROLE_REQUIRED', + `Access Denied: Required roles [${requiredRoles.join(', ')}]`, + `ต้องมี Role: [${requiredRoles.join(', ')}] จึงจะดำเนินการนี้ได้`, + ['ติดต่อผู้ดูแลระบบเพื่อขอสิทธิ์'] ); } } // Check Specific User if (req.userId && String(req.userId) !== String(userId)) { - throw new BadRequestException('Access Denied: User mismatch.'); + throw new WorkflowException( + 'WORKFLOW_USER_MISMATCH', + 'Access Denied: User mismatch', + 'ผู้ใช้ไม่ได้รับอนุญาตให้ดำเนินการนี้', + ['ตรวจสอบว่าเล็็กชื่ออีเมลที่ป้อนให้เข้าสู่ระบบ'] + ); } } diff --git a/backend/src/modules/workflow-engine/workflow-engine.service.ts b/backend/src/modules/workflow-engine/workflow-engine.service.ts index a81518f..11ebff3 100644 --- a/backend/src/modules/workflow-engine/workflow-engine.service.ts +++ b/backend/src/modules/workflow-engine/workflow-engine.service.ts @@ -1,14 +1,9 @@ // File: src/modules/workflow-engine/workflow-engine.service.ts -import { - BadRequestException, - Injectable, - Logger, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { NotFoundException, WorkflowException } from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; - // Entities import { WorkflowDefinition } from './entities/workflow-definition.entity'; import { WorkflowHistory } from './entities/workflow-history.entity'; @@ -104,9 +99,7 @@ export class WorkflowEngineService { ): Promise { const definition = await this.workflowDefRepo.findOne({ where: { id } }); if (!definition) { - throw new NotFoundException( - `Workflow Definition with ID "${id}" not found` - ); + throw new NotFoundException('Workflow Definition', id); } if (dto.dsl) { @@ -115,8 +108,11 @@ export class WorkflowEngineService { definition.dsl = dto.dsl as unknown as Record; definition.compiled = compiled as unknown as Record; } catch (error: unknown) { - throw new BadRequestException( - `Invalid DSL: ${error instanceof Error ? error.message : String(error)}` + throw new WorkflowException( + 'INVALID_WORKFLOW_DSL', + `Invalid DSL: ${error instanceof Error ? error.message : String(error)}`, + 'Workflow DSL ไม่ถูกต้อง กรุณาตรวจสอบโครงสร้าง', + ['ตรวจสอบ syntax ของ DSL', 'ดูตัวอย่าง DSL ที่ถูกต้อง'] ); } } @@ -149,9 +145,7 @@ export class WorkflowEngineService { async getDefinitionById(id: string): Promise { const definition = await this.workflowDefRepo.findOne({ where: { id } }); if (!definition) { - throw new NotFoundException( - `Workflow Definition with ID "${id}" not found` - ); + throw new NotFoundException('Workflow Definition', id); } return definition; } @@ -197,9 +191,7 @@ export class WorkflowEngineService { }); if (!definition) { - throw new NotFoundException( - `Workflow "${workflowCode}" not found or inactive.` - ); + throw new NotFoundException('Workflow', workflowCode); } // 2. หา Initial State จาก Compiled Structure @@ -209,8 +201,11 @@ export class WorkflowEngineService { const initialState = compiled.initialState; if (!initialState) { - throw new BadRequestException( - `Workflow "${workflowCode}" has no initial state defined.` + throw new WorkflowException( + 'WORKFLOW_NO_INITIAL_STATE', + `Workflow "${workflowCode}" has no initial state defined`, + 'Workflow ไม่มี Initial State ที่กำหนด', + ['ตรวจสอบ DSL ของ Workflow นี้'] ); } @@ -242,9 +237,7 @@ export class WorkflowEngineService { }); if (!instance) { - throw new NotFoundException( - `Workflow Instance "${instanceId}" not found` - ); + throw new NotFoundException('Workflow Instance', instanceId); } return instance; @@ -276,14 +269,15 @@ export class WorkflowEngineService { }); if (!instance) { - throw new NotFoundException( - `Workflow Instance "${instanceId}" not found.` - ); + throw new NotFoundException('Workflow Instance', instanceId); } if (instance.status !== WorkflowStatus.ACTIVE) { - throw new BadRequestException( - `Workflow is not active (Status: ${instance.status}).` + throw new WorkflowException( + 'WORKFLOW_NOT_ACTIVE', + `Workflow is not active (Status: ${instance.status})`, + 'Workflow ไม่อยู่ในสถานะ Active', + ['ตรวจสอบสถานะของ Workflow'] ); } @@ -427,7 +421,12 @@ export class WorkflowEngineService { case 'RETURN': { const targetStep = returnToSequence || currentSequence - 1; if (targetStep < 1) { - throw new BadRequestException('Cannot return beyond the first step'); + throw new WorkflowException( + 'WORKFLOW_INVALID_RETURN_TARGET', + 'Cannot return beyond the first step', + 'ไม่สามารถส่งคืนไปเกินกว่าขั้นตอนแรกได้', + ['ตรวจสอบลำดับขั้นตอนที่ต้องการส่งคืน'] + ); } return { nextStepSequence: targetStep, diff --git a/docs/error-catalog.md b/docs/error-catalog.md new file mode 100644 index 0000000..3948cbb --- /dev/null +++ b/docs/error-catalog.md @@ -0,0 +1,192 @@ +# Error Catalog + +**Version:** 1.0 +**ADR Compliance:** ADR-007 (Error Handling & Recovery Strategy) +**Last Updated:** 2026-04-06 + +--- + +## Error Format (ADR-007) + +All API errors follow this structure: + +```json +{ + "error": { + "type": "VALIDATION", + "code": "VALIDATION_ERROR", + "message": "ข้อมูลที่กรอกไม่ถูกต้อง กรุณาตรวจสอบและลองใหม่", + "severity": "LOW", + "timestamp": "2026-04-06T10:00:00.000Z", + "statusCode": 400, + "recoveryActions": ["ตรวจสอบข้อมูลที่กรอก", "ลองใหม่อีกครั้ง"] + } +} +``` + +--- + +## Error Types + +| Error Type | HTTP Status | Description | +|------------|-------------|-------------| +| `VALIDATION` | 400 | ข้อมูล Input ไม่ถูกต้อง | +| `BUSINESS_RULE` | 422 | ละเมิดกฎทางธุรกิจ | +| `PERMISSION_DENIED` | 403 | ไม่มีสิทธิ์ดำเนินการ | +| `NOT_FOUND` | 404 | ไม่พบข้อมูลที่ร้องขอ | +| `CONFLICT` | 409 | ข้อมูลซ้ำหรือขัดแย้ง | +| `INTERNAL_ERROR` | 500 | ปัญหาระบบภายใน | +| `DATABASE_ERROR` | 500 | ปัญหาฐานข้อมูล | +| `EXTERNAL_SERVICE` | 500 | บริการภายนอกมีปัญหา | +| `INFRASTRUCTURE` | 500 | ปัญหาโครงสร้างพื้นฐาน | + +## Severity Levels + +| Severity | Description | User Impact | +|----------|-------------|-------------| +| `LOW` | ผู้ใช้ทำผิด แก้ไขง่าย | แก้ไขข้อมูลได้ทันที | +| `MEDIUM` | ละเมิดกฎทางธุรกิจ | ต้องดำเนินการก่อน | +| `HIGH` | ปัญหาระบบ | อาจต้องติดต่อ Support | +| `CRITICAL` | ระบบล้มเหลว | ต้องแก้ไขทันที | + +--- + +## Error Codes Reference + +### General Errors + +| Error Code | Type | HTTP | User Message | Severity | Module | +|------------|------|------|--------------|----------|--------| +| `VALIDATION_ERROR` | VALIDATION | 400 | ข้อมูลที่กรอกไม่ถูกต้อง | LOW | All | +| `NOT_FOUND` | NOT_FOUND | 404 | ไม่พบข้อมูลที่ร้องขอ | LOW | All | +| `PERMISSION_DENIED` | PERMISSION_DENIED | 403 | ไม่มีสิทธิ์ดำเนินการ | MEDIUM | All | +| `INTERNAL_ERROR` | INTERNAL_ERROR | 500 | เกิดข้อผิดพลาดในระบบ | HIGH | All | +| `DATABASE_ERROR` | DATABASE_ERROR | 500 | เกิดข้อผิดพลาดของฐานข้อมูล | HIGH | All | +| `NETWORK_ERROR` | INFRASTRUCTURE | N/A | ไม่สามารถเชื่อมต่อได้ | HIGH | Frontend | + +### Correspondence Errors + +| Error Code | Type | HTTP | User Message | Severity | Recovery Actions | +|------------|------|------|--------------|----------|-----------------| +| `INVALID_DOCUMENT_TYPE` | BUSINESS_RULE | 422 | การสื่อสารภายในควรใช้ Circulation Sheet | MEDIUM | ใช้ Circulation Sheet | +| `CORRESPONDENCE_TO_SELF` | BUSINESS_RULE | 422 | ไม่สามารถส่งถึงองค์กรตัวเองได้ | MEDIUM | ใช้ Circulation Sheet | +| `SELF_REFERENCE` | BUSINESS_RULE | 422 | ไม่สามารถอ้างอิงเอกสารเดียวกัน | MEDIUM | เลือกเอกสารอื่น | + +### RFA Errors + +| Error Code | Type | HTTP | User Message | Severity | Recovery Actions | +|------------|------|------|--------------|----------|-----------------| +| `RFA_TYPE_CONTRACT_MISMATCH` | BUSINESS_RULE | 422 | ประเภท RFA ไม่ตรงกับสัญญา | MEDIUM | เลือกประเภท RFA ที่ถูกต้อง | +| `DISCIPLINE_CONTRACT_MISMATCH` | BUSINESS_RULE | 422 | Discipline ไม่ตรงกับสัญญา | MEDIUM | เลือก Discipline ที่ถูกต้อง | +| `EC_RFA_001_ACTIVE_RFA_EXISTS` | BUSINESS_RULE | 422 | Shop Drawing มี RFA ที่ใช้งานอยู่แล้ว | MEDIUM | ตรวจสอบ RFA ที่มีอยู่ | +| `ROUTING_TEMPLATE_NOT_FOUND` | BUSINESS_RULE | 422 | ไม่พบ Routing Template | MEDIUM | ตรวจสอบ Template | +| `ROUTING_TEMPLATE_EMPTY` | BUSINESS_RULE | 422 | Routing Template ไม่มีขั้นตอน | MEDIUM | เพิ่ม Step ใน Template | +| `RFA_INVALID_SUBMIT_STATUS` | BUSINESS_RULE | 422 | ส่งได้เฉพาะ DRAFT เท่านั้น | MEDIUM | ตรวจสอบสถานะ | +| `RFA_EDIT_NON_DRAFT` | BUSINESS_RULE | 422 | แก้ไขได้เฉพาะ DRAFT เท่านั้น | MEDIUM | สร้าง Revision ใหม่ | +| `NO_ACTIVE_WORKFLOW_STEP` | BUSINESS_RULE | 422 | ไม่พบ Workflow Step ที่เปิดอยู่ | MEDIUM | ตรวจสอบสถานะ Workflow | + +### Workflow Engine Errors + +| Error Code | Type | HTTP | User Message | Severity | Recovery Actions | +|------------|------|------|--------------|----------|-----------------| +| `WORKFLOW_NOT_ACTIVE` | BUSINESS_RULE | 422 | Workflow ไม่อยู่ในสถานะ Active | MEDIUM | ตรวจสอบสถานะ Workflow | +| `WORKFLOW_NO_INITIAL_STATE` | BUSINESS_RULE | 422 | Workflow ไม่มี Initial State | MEDIUM | ตรวจสอบ DSL | +| `WORKFLOW_INVALID_CURRENT_STATE` | BUSINESS_RULE | 422 | Workflow อยู่ในสถานะที่ไม่รู้จัก | MEDIUM | ตรวจสอบ DSL | +| `WORKFLOW_TERMINAL_STATE` | BUSINESS_RULE | 422 | ไม่สามารถดำเนินการจาก State สุดท้าย | MEDIUM | เอกสารสิ้นสุดแล้ว | +| `WORKFLOW_INVALID_ACTION` | BUSINESS_RULE | 422 | ไม่สามารถดำเนินการนี้ในสถานะปัจจุบัน | MEDIUM | เลือกการดำเนินการที่อนุญาต | +| `WORKFLOW_ROLE_REQUIRED` | BUSINESS_RULE | 422 | ต้องมี Role ที่กำหนด | MEDIUM | ขอสิทธิ์จาก Admin | +| `WORKFLOW_USER_MISMATCH` | BUSINESS_RULE | 422 | ผู้ใช้ไม่ได้รับอนุญาต | MEDIUM | ตรวจสอบบัญชีที่ใช้ | +| `WORKFLOW_CONDITION_NOT_MET` | BUSINESS_RULE | 422 | เงื่อนไขสำหรับการดำเนินการไม่ผ่าน | MEDIUM | ตรวจสอบเงื่อนไข | +| `WORKFLOW_INVALID_RETURN_TARGET` | BUSINESS_RULE | 422 | ไม่สามารถส่งคืนไปเกินขั้นตอนแรก | MEDIUM | ตรวจสอบลำดับขั้นตอน | + +### DSL Validation Errors + +| Error Code | Type | HTTP | User Message | Severity | Recovery Actions | +|------------|------|------|--------------|----------|-----------------| +| `DSL_MULTIPLE_INITIAL_STATES` | BUSINESS_RULE | 422 | DSL มี Initial State หลายค่า | MEDIUM | แก้ไข DSL | +| `DSL_UNKNOWN_TRANSITION_TARGET` | BUSINESS_RULE | 422 | DSL อ้างอิง State ที่ไม่พบ | MEDIUM | ตรวจสอบชื่อ State | +| `DSL_NO_INITIAL_STATE` | BUSINESS_RULE | 422 | DSL ไม่มี Initial State | MEDIUM | เพิ่ม initial: true | +| `INVALID_WORKFLOW_DSL` | BUSINESS_RULE | 422 | Workflow DSL ไม่ถูกต้อง | MEDIUM | ตรวจสอบ syntax | + +--- + +## Exception Classes Reference + +| Class | Code | HTTP | Usage | +|-------|------|------|-------| +| `ValidationException` | `VALIDATION_ERROR` | 400 | Input validation failures | +| `BusinessException` | custom | 422 | Business rule violations | +| `NotFoundException` | `NOT_FOUND` | 404 | Resource not found | +| `PermissionException` | `PERMISSION_DENIED` | 403 | RBAC failures | +| `ConflictException` | custom | 409 | Duplicate/conflict | +| `WorkflowException` | custom | 422 | Workflow state/transition errors | +| `SystemException` | `INTERNAL_ERROR` | 500 | Infrastructure issues | +| `DatabaseException` | `DATABASE_ERROR` | 500 | DB failures | + +--- + +## Developer Guidelines + +### When to use each exception + +```typescript +// ✅ Input validation (user made a mistake) +throw new ValidationException('User must belong to an organization'); + +// ✅ Resource not found +throw new NotFoundException('Correspondence', publicId); + +// ✅ Business rule violation +throw new BusinessException( + 'EC_RFA_001_ACTIVE_RFA_EXISTS', + 'Technical message for logs', + 'Thai user message', + ['Recovery action 1', 'Recovery action 2'] +); + +// ✅ Permission denied (RBAC) +throw new PermissionException('correspondence', 'cancel'); + +// ✅ Workflow state error +throw new WorkflowException( + 'WORKFLOW_INVALID_ACTION', + 'Technical message', + 'Thai user message', + ['Recovery actions'] +); + +// ✅ Master data / config missing (internal system issue) +throw new SystemException('Status DRAFT not found in Master Data'); +``` + +### Anti-patterns (Do NOT use) + +```typescript +// ❌ Never use NestJS built-in exceptions in service layer +import { NotFoundException } from '@nestjs/common'; // WRONG + +// ❌ Never use generic error messages +throw new SystemException('Error'); // Too vague + +// ❌ Never expose technical details in user messages +throw new BusinessException( + 'DB_ERROR', + 'DB error', + 'SQL constraint error: ...' // Exposes internals +); +``` + +--- + +## Adding New Error Codes + +1. Add entry to this catalog with full details +2. Add `code` constant if reused often (optional) +3. Use `BusinessException` or specific class +4. Provide Thai user message + recovery actions +5. Update test coverage + +--- + +**Maintainers:** Backend Team Lead +**Review Cycle:** Every 6 months (next: 2026-10-06) diff --git a/frontend/components/common/error-display.tsx b/frontend/components/common/error-display.tsx new file mode 100644 index 0000000..ee927b3 --- /dev/null +++ b/frontend/components/common/error-display.tsx @@ -0,0 +1,217 @@ +'use client'; + +// File: frontend/components/common/error-display.tsx +// ADR-007: Component แสดง Error พร้อม Recovery Actions สำหรับ User + +import { AlertTriangle, XCircle, Info } from 'lucide-react'; + +// รูปแบบ Error Response จาก Backend (ADR-007) +export interface ApiErrorPayload { + type: string; + code: string; + message: string; + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + timestamp: string; + statusCode?: number; + recoveryActions?: string[]; + technicalMessage?: string; + details?: Record; +} + +export interface ApiErrorResponse { + error: ApiErrorPayload; +} + +interface ErrorDisplayProps { + error: ApiErrorResponse | ApiErrorPayload | null | undefined; + onRetry?: () => void; + className?: string; + compact?: boolean; +} + +// แปลง severity เป็น color class +function getSeverityStyles(severity: string): { + container: string; + icon: string; + title: string; + iconComponent: React.ElementType; +} { + switch (severity) { + case 'LOW': + return { + container: 'border-yellow-200 bg-yellow-50', + icon: 'text-yellow-400', + title: 'text-yellow-800', + iconComponent: Info, + }; + case 'MEDIUM': + return { + container: 'border-orange-200 bg-orange-50', + icon: 'text-orange-400', + title: 'text-orange-800', + iconComponent: AlertTriangle, + }; + case 'HIGH': + return { + container: 'border-red-200 bg-red-50', + icon: 'text-red-400', + title: 'text-red-700', + iconComponent: AlertTriangle, + }; + case 'CRITICAL': + return { + container: 'border-red-300 bg-red-100', + icon: 'text-red-500', + title: 'text-red-900', + iconComponent: XCircle, + }; + default: + return { + container: 'border-gray-200 bg-gray-50', + icon: 'text-gray-400', + title: 'text-gray-700', + iconComponent: AlertTriangle, + }; + } +} + +// ดึง ErrorPayload ออกจาก response ที่อาจซ้อนอยู่ +function extractErrorPayload( + error: ApiErrorResponse | ApiErrorPayload | null | undefined +): ApiErrorPayload | null { + if (!error) return null; + + // กรณีที่ error เป็น { error: { ... } } + if ('error' in error && error.error && typeof error.error === 'object') { + return error.error as ApiErrorPayload; + } + + // กรณีที่ error เป็น payload โดยตรง + if ('type' in error && 'message' in error) { + return error as ApiErrorPayload; + } + + return null; +} + +export function ErrorDisplay({ error, onRetry, className = '', compact = false }: ErrorDisplayProps) { + const payload = extractErrorPayload(error); + + if (!payload) return null; + + const styles = getSeverityStyles(payload.severity); + const IconComponent = styles.iconComponent; + + if (compact) { + return ( +
+
+ +

{payload.message}

+
+
+ ); + } + + return ( +
+
+
+
+
+ {/* หัวข้อ Error */} +

+ {payload.message} +

+ + {/* Recovery Actions */} + {payload.recoveryActions && payload.recoveryActions.length > 0 && ( +
+

วิธีแก้ไข:

+
    + {payload.recoveryActions.map((action, index) => ( +
  • {action}
  • + ))} +
+
+ )} + + {/* Error Code (แสดงเฉพาะ Development) */} + {process.env.NODE_ENV === 'development' && payload.technicalMessage && ( +
+ + รายละเอียดทางเทคนิค (Development) + +
+                {payload.technicalMessage}
+              
+
+ )} + + {/* Action Buttons */} +
+ {onRetry && ( + + )} + +
+
+
+
+ ); +} + +// Helper: แปลง Axios/Fetch error เป็น ApiErrorResponse +export function parseApiError(error: unknown): ApiErrorResponse { + // กรณี error มาจาก Axios + if ( + error !== null && + typeof error === 'object' && + 'response' in error && + (error as { response?: { data?: unknown } }).response?.data + ) { + const data = (error as { response: { data: unknown } }).response.data; + if ( + typeof data === 'object' && + data !== null && + 'error' in data + ) { + return data as ApiErrorResponse; + } + } + + // กรณี error เป็น ApiErrorResponse อยู่แล้ว + if ( + error !== null && + typeof error === 'object' && + 'error' in error && + (error as ApiErrorResponse).error?.message + ) { + return error as ApiErrorResponse; + } + + // กรณี Network Error หรือ Unknown + return { + error: { + type: 'INTERNAL_ERROR', + code: 'NETWORK_ERROR', + message: 'ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้', + severity: 'HIGH', + timestamp: new Date().toISOString(), + recoveryActions: ['ตรวจสอบการเชื่อมต่ออินเทอร์เน็ต', 'ลองใหม่ภายหลัง'], + }, + }; +} diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 964612a..04a673f 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -90,6 +90,78 @@ apiClient.interceptors.request.use( // Response Interceptors // --------------------------------------------------------------------------- +// รูปแบบ Error Response จาก Backend (ADR-007) +export interface ApiErrorPayload { + type: string; + code: string; + message: string; + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + timestamp: string; + statusCode?: number; + recoveryActions?: string[]; + technicalMessage?: string; + details?: Record; +} + +export interface ApiErrorResponse { + error: ApiErrorPayload; +} + +// แปลง Axios error เป็น Structured Error Response (ADR-007) +export function parseApiError(axiosError: AxiosError): ApiErrorResponse { + if (axiosError.response?.data) { + const data = axiosError.response.data; + // กรณีที่ backend ส่ง { error: { ... } } ตาม ADR-007 + if (typeof data === 'object' && data !== null && 'error' in data) { + return data as ApiErrorResponse; + } + // กรณี NestJS validation error { message: [...], statusCode: 400 } + if (typeof data === 'object' && data !== null && 'message' in data) { + const status = axiosError.response.status; + return { + error: { + type: 'VALIDATION', + code: 'HTTP_ERROR', + message: Array.isArray((data as Record).message) + ? 'ข้อมูลที่กรอกไม่ถูกต้อง กรุณาตรวจสอบและลองใหม่' + : String((data as Record).message), + severity: status >= 500 ? 'HIGH' : 'MEDIUM', + timestamp: new Date().toISOString(), + statusCode: status, + recoveryActions: ['ตรวจสอบข้อมูลที่กรอก', 'แก้ไขข้อมูลที่ผิดพลาด', 'ลองใหม่อีกครั้ง'], + }, + }; + } + } + + // กรณี Network Error + if (!axiosError.response) { + return { + error: { + type: 'INFRASTRUCTURE', + code: 'NETWORK_ERROR', + message: 'ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้', + severity: 'HIGH', + timestamp: new Date().toISOString(), + recoveryActions: ['ตรวจสอบการเชื่อมต่ออินเทอร์เน็ต', 'ลองใหม่ภายหลัง'], + }, + }; + } + + // Fallback + return { + error: { + type: 'INTERNAL_ERROR', + code: 'UNKNOWN_ERROR', + message: 'เกิดข้อผิดพลาดที่ไม่คาดคิด กรุณาลองใหม่ภายหลัง', + severity: 'HIGH', + timestamp: new Date().toISOString(), + statusCode: axiosError.response?.status, + recoveryActions: ['ลองใหม่อีกครั้ง', 'ติดต่อผู้ดูแลระบบหากยังพบปัญหา'], + }, + }; +} + apiClient.interceptors.response.use( (response) => { return response; @@ -107,7 +179,9 @@ apiClient.interceptors.response.use( } } } - return Promise.reject(error); + // แปลง error เป็น structured format ตาม ADR-007 ก่อน reject + const structuredError = parseApiError(error); + return Promise.reject(structuredError); } ); diff --git a/specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md b/specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md index 84ef144..d7d454d 100644 --- a/specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md +++ b/specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md @@ -526,7 +526,7 @@ OUTPUT FORMAT: ### Architecture Decision Records - **[ADR-017: Ollama Data Migration](./ADR-017-ollama-data-migration.md)** — Foundation migration architecture -- **[ADR-017B: AI Document Classification](./ADR-017B-ai-document-classification.md)** — AI classification use cases +- **[ADR-017B: Smart Categorization](./ADR-017B-ollama.md)** — AI categorization use cases - **[ADR-018: AI Boundary Policy](./ADR-018-ai-boundary.md)** — Security isolation requirements (CRITICAL) - **[ADR-019: Hybrid Identifier Strategy](./ADR-019-hybrid-identifier-strategy.md)** — UUID usage patterns (CRITICAL)