690406:2310 Done Task BE-ERR-01
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
// 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<string, unknown>,
|
||||
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<string, unknown>) {
|
||||
super(
|
||||
ErrorType.INTERNAL_ERROR,
|
||||
'INTERNAL_ERROR',
|
||||
message,
|
||||
'เกิดข้อผิดพลาดในระบบ กรุณาลองใหม่ภายหลัง',
|
||||
ErrorSeverity.HIGH,
|
||||
details,
|
||||
['ลองใหม่อีกครั้ง', 'ติดต่อผู้ดูแลระบบหากยังพบปัญหา']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Database Errors (500) - ปัญหาฐานข้อมูล
|
||||
export class DatabaseException extends BaseException {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(
|
||||
ErrorType.DATABASE_ERROR,
|
||||
'DATABASE_ERROR',
|
||||
message,
|
||||
'เกิดข้อผิดพลาดของฐานข้อมูล กรุณาลองใหม่ภายหลัง',
|
||||
ErrorSeverity.HIGH,
|
||||
details,
|
||||
['ลองใหม่ภายหลัง', 'แจ้งผู้ดูแลระบบหากยังพบปัญหา']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<Response>();
|
||||
const request = ctx.getRequest<RequestWithUser>();
|
||||
|
||||
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<string, unknown>,
|
||||
}),
|
||||
},
|
||||
};
|
||||
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 ['ลองใหม่อีกครั้ง', 'ติดต่อผู้ดูแลระบบหากยังพบปัญหา'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 แทน']
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<JsonSchema> {
|
||||
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)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 เป้าหมาย'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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".`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ที่บันทึกไว้ไม่ถูกต้อง'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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',
|
||||
'ผู้ใช้ไม่ได้รับอนุญาตให้ดำเนินการนี้',
|
||||
['ตรวจสอบว่าเล็็กชื่ออีเมลที่ป้อนให้เข้าสู่ระบบ']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WorkflowDefinition> {
|
||||
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<string, unknown>;
|
||||
definition.compiled = compiled as unknown as Record<string, unknown>;
|
||||
} 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<WorkflowDefinition> {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`rounded-md border p-3 ${styles.container} ${className}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<IconComponent className={`mt-0.5 h-4 w-4 flex-shrink-0 ${styles.icon}`} />
|
||||
<p className={`text-sm ${styles.title}`}>{payload.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 ${styles.container} ${className}`} role="alert">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<IconComponent className={`h-5 w-5 ${styles.icon}`} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
{/* หัวข้อ Error */}
|
||||
<h3 className={`text-sm font-medium ${styles.title}`}>
|
||||
{payload.message}
|
||||
</h3>
|
||||
|
||||
{/* Recovery Actions */}
|
||||
{payload.recoveryActions && payload.recoveryActions.length > 0 && (
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-700">วิธีแก้ไข:</p>
|
||||
<ul className="mt-1 list-inside list-disc space-y-0.5">
|
||||
{payload.recoveryActions.map((action, index) => (
|
||||
<li key={index}>{action}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Code (แสดงเฉพาะ Development) */}
|
||||
{process.env.NODE_ENV === 'development' && payload.technicalMessage && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-gray-500 hover:text-gray-700">
|
||||
รายละเอียดทางเทคนิค (Development)
|
||||
</summary>
|
||||
<pre className="mt-1 overflow-x-auto rounded bg-gray-100 p-2 text-xs text-gray-600">
|
||||
{payload.technicalMessage}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open('mailto:support@np-dms.work', '_blank')}
|
||||
className="rounded-md bg-gray-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-1"
|
||||
>
|
||||
ติดต่อผู้ดูแลระบบ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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: ['ตรวจสอบการเชื่อมต่ออินเทอร์เน็ต', 'ลองใหม่ภายหลัง'],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>).message)
|
||||
? 'ข้อมูลที่กรอกไม่ถูกต้อง กรุณาตรวจสอบและลองใหม่'
|
||||
: String((data as Record<string, unknown>).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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user