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 { RequestContextService } from './services/request-context.service';
|
||||||
import { UuidResolverService } from './services/uuid-resolver.service';
|
import { UuidResolverService } from './services/uuid-resolver.service';
|
||||||
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
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 { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||||
// import { IdempotencyInterceptor } from './interceptors/idempotency.interceptor'; // นำเข้าถ้าต้องการใช้ Global
|
// import { IdempotencyInterceptor } from './interceptors/idempotency.interceptor'; // นำเข้าถ้าต้องการใช้ Global
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
|
|||||||
// Register Global Filter & Interceptor ที่นี่ หรือใน AppModule ก็ได้
|
// Register Global Filter & Interceptor ที่นี่ หรือใน AppModule ก็ได้
|
||||||
{
|
{
|
||||||
provide: APP_FILTER,
|
provide: APP_FILTER,
|
||||||
useClass: HttpExceptionFilter,
|
useClass: GlobalExceptionFilter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: APP_INTERCEPTOR,
|
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 {
|
import {
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
BadRequestException,
|
PermissionException,
|
||||||
ForbiddenException,
|
ValidationException,
|
||||||
} from '@nestjs/common';
|
} from '../../common/exceptions';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
|
||||||
@@ -53,15 +53,18 @@ export class CirculationService {
|
|||||||
user.user_id
|
user.user_id
|
||||||
);
|
);
|
||||||
if (!canManageAll) {
|
if (!canManageAll) {
|
||||||
throw new ForbiddenException(
|
throw new PermissionException(
|
||||||
'You do not have permission to create documents on behalf of other organizations.'
|
'circulation',
|
||||||
|
'create on behalf of other organization'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
userOrgId = resolvedOriginatorId;
|
userOrgId = resolvedOriginatorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userOrgId) {
|
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();
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
@@ -195,11 +198,12 @@ export class CirculationService {
|
|||||||
relations: ['circulation'],
|
relations: ['circulation'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!routing) throw new NotFoundException('Routing task not found');
|
if (!routing)
|
||||||
|
throw new NotFoundException('Routing task', String(routingId));
|
||||||
|
|
||||||
// Check Permission: คนทำต้องเป็นเจ้าของ Task
|
// Check Permission: คนทำต้องเป็นเจ้าของ Task
|
||||||
if (routing.assignedTo !== user.user_id) {
|
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
|
// Update Routing
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { ForbiddenException } from '@nestjs/common';
|
import { PermissionException } from '../../common/exceptions';
|
||||||
import { CorrespondenceService } from './correspondence.service';
|
import { CorrespondenceService } from './correspondence.service';
|
||||||
import { Correspondence } from './entities/correspondence.entity';
|
import { Correspondence } from './entities/correspondence.entity';
|
||||||
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
|
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
|
||||||
@@ -260,7 +260,7 @@ describe('CorrespondenceService', () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.update(2, { subject: 'Should Fail' }, mockUser)
|
service.update(2, { subject: 'Should Fail' }, mockUser)
|
||||||
).rejects.toThrow(ForbiddenException);
|
).rejects.toThrow(PermissionException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT regenerate number if critical fields unchanged', async () => {
|
it('should NOT regenerate number if critical fields unchanged', async () => {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// File: src/modules/correspondence/correspondence.service.ts
|
// File: src/modules/correspondence/correspondence.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Injectable,
|
BusinessException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
BadRequestException,
|
PermissionException,
|
||||||
InternalServerErrorException,
|
SystemException,
|
||||||
ForbiddenException,
|
ValidationException,
|
||||||
Logger,
|
} from '../../common/exceptions';
|
||||||
} from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ export class CorrespondenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!userOrgId) {
|
if (!userOrgId) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'User must belong to an organization to create documents'
|
'User must belong to an organization to create documents'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -139,14 +139,17 @@ export class CorrespondenceService {
|
|||||||
// Check if it's internal communication
|
// Check if it's internal communication
|
||||||
if (createDto.isInternal) {
|
if (createDto.isInternal) {
|
||||||
// Internal communications should use Circulation instead
|
// Internal communications should use Circulation instead
|
||||||
throw new BadRequestException(
|
throw new BusinessException(
|
||||||
'Internal communications should use Circulation Sheet instead of Correspondence'
|
'INVALID_DOCUMENT_TYPE',
|
||||||
|
'Internal communications should use Circulation Sheet instead of Correspondence',
|
||||||
|
'การสื่อสารภายในควรใช้ Circulation Sheet แทน Correspondence',
|
||||||
|
['ใช้ Circulation Sheet สำหรับการสื่อสารภายในองค์กร']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate recipients
|
// Validate recipients
|
||||||
if (!createDto.recipients || createDto.recipients.length === 0) {
|
if (!createDto.recipients || createDto.recipients.length === 0) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'At least one recipient (TO or CC) is required'
|
'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');
|
const ccRecipients = createDto.recipients.filter((r) => r.type === 'CC');
|
||||||
|
|
||||||
if (toRecipients.length === 0 && ccRecipients.length === 0) {
|
if (toRecipients.length === 0 && ccRecipients.length === 0) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'At least one TO or CC recipient is required'
|
'At least one TO or CC recipient is required'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -167,8 +170,11 @@ export class CorrespondenceService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (recipientOrgId === originatorOrgId) {
|
if (recipientOrgId === originatorOrgId) {
|
||||||
throw new BadRequestException(
|
throw new BusinessException(
|
||||||
'Cannot send correspondence to your own organization. Use Circulation Sheet for internal communication.'
|
'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({
|
const type = await this.typeRepo.findOne({
|
||||||
where: { id: createDto.typeId },
|
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({
|
const statusDraft = await this.statusRepo.findOne({
|
||||||
where: { statusCode: 'DRAFT' },
|
where: { statusCode: 'DRAFT' },
|
||||||
});
|
});
|
||||||
if (!statusDraft) {
|
if (!statusDraft) {
|
||||||
throw new InternalServerErrorException(
|
throw new SystemException('Status DRAFT not found in Master Data');
|
||||||
'Status DRAFT not found in Master Data'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let userOrgId = user.primaryOrganizationId;
|
let userOrgId = user.primaryOrganizationId;
|
||||||
@@ -225,15 +230,16 @@ export class CorrespondenceService {
|
|||||||
user.user_id
|
user.user_id
|
||||||
);
|
);
|
||||||
if (!canManageAll) {
|
if (!canManageAll) {
|
||||||
throw new ForbiddenException(
|
throw new PermissionException(
|
||||||
'You do not have permission to create documents on behalf of other organizations.'
|
'correspondence',
|
||||||
|
'create on behalf of other organization'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
userOrgId = resolvedOriginatorId;
|
userOrgId = resolvedOriginatorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userOrgId) {
|
if (!userOrgId) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'User must belong to an organization to create documents'
|
'User must belong to an organization to create documents'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -505,7 +511,7 @@ export class CorrespondenceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!correspondence) {
|
if (!correspondence) {
|
||||||
throw new NotFoundException(`Correspondence with ID ${id} not found`);
|
throw new NotFoundException('Correspondence', String(id));
|
||||||
}
|
}
|
||||||
return correspondence;
|
return correspondence;
|
||||||
}
|
}
|
||||||
@@ -533,9 +539,7 @@ export class CorrespondenceService {
|
|||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (!correspondence) {
|
if (!correspondence) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('Correspondence', publicId);
|
||||||
`Correspondence with UUID ${publicId} not found`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return correspondence;
|
return correspondence;
|
||||||
}
|
}
|
||||||
@@ -548,11 +552,15 @@ export class CorrespondenceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!source || !target) {
|
if (!source || !target) {
|
||||||
throw new NotFoundException('Source or Target correspondence not found');
|
throw new NotFoundException('Source or Target correspondence');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source.id === target.id) {
|
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({
|
const exists = await this.referenceRepo.findOne({
|
||||||
@@ -581,7 +589,7 @@ export class CorrespondenceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.affected === 0) {
|
if (result.affected === 0) {
|
||||||
throw new NotFoundException('Reference not found');
|
throw new NotFoundException('Reference');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,14 +606,14 @@ export class CorrespondenceService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
if (!correspondence) {
|
if (!correspondence) {
|
||||||
throw new NotFoundException(`Correspondence ${id} not found`);
|
throw new NotFoundException('Correspondence', String(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const tag = await this.dataSource.manager.findOne(Tag, {
|
const tag = await this.dataSource.manager.findOne(Tag, {
|
||||||
where: { id: tagId },
|
where: { id: tagId },
|
||||||
});
|
});
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
throw new NotFoundException(`Tag ${tagId} not found`);
|
throw new NotFoundException('Tag', String(tagId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const exists = await this.tagRepo.findOne({
|
const exists = await this.tagRepo.findOne({
|
||||||
@@ -620,7 +628,7 @@ export class CorrespondenceService {
|
|||||||
async removeTag(id: number, tagId: number) {
|
async removeTag(id: number, tagId: number) {
|
||||||
const result = await this.tagRepo.delete({ correspondenceId: id, tagId });
|
const result = await this.tagRepo.delete({ correspondenceId: id, tagId });
|
||||||
if (result.affected === 0) {
|
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) {
|
if (!revision) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('Current revision', `correspondence:${id}`);
|
||||||
`Current revision for correspondence ${id} not found`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check Permission
|
// 2. Check Permission
|
||||||
@@ -669,9 +675,7 @@ export class CorrespondenceService {
|
|||||||
permissions.includes('system.manage_all');
|
permissions.includes('system.manage_all');
|
||||||
|
|
||||||
if (!canEditSubmittedOrLater) {
|
if (!canEditSubmittedOrLater) {
|
||||||
throw new ForbiddenException(
|
throw new PermissionException('correspondence', 'edit non-draft');
|
||||||
'Only Org Admin or Superadmin can edit non-draft correspondences'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,7 +703,7 @@ export class CorrespondenceService {
|
|||||||
// 3. Check if number regeneration is needed (only for DRAFT status)
|
// 3. Check if number regeneration is needed (only for DRAFT status)
|
||||||
const oldCorr = revision.correspondence;
|
const oldCorr = revision.correspondence;
|
||||||
if (!oldCorr) {
|
if (!oldCorr) {
|
||||||
throw new InternalServerErrorException(
|
throw new SystemException(
|
||||||
'Correspondence relation not loaded for revision'
|
'Correspondence relation not loaded for revision'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -734,7 +738,7 @@ export class CorrespondenceService {
|
|||||||
const type = await this.typeRepo.findOne({ where: { id: typeId } });
|
const type = await this.typeRepo.findOne({ where: { id: typeId } });
|
||||||
|
|
||||||
if (!type) {
|
if (!type) {
|
||||||
throw new NotFoundException('Document Type not found');
|
throw new NotFoundException('Document Type', String(typeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get recipient org code for number generation
|
// Get recipient org code for number generation
|
||||||
@@ -898,7 +902,8 @@ export class CorrespondenceService {
|
|||||||
const type = await this.typeRepo.findOne({
|
const type = await this.typeRepo.findOne({
|
||||||
where: { id: createDto.typeId },
|
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;
|
let userOrgId = user.primaryOrganizationId;
|
||||||
if (!userOrgId) {
|
if (!userOrgId) {
|
||||||
@@ -953,9 +958,7 @@ export class CorrespondenceService {
|
|||||||
permissions.includes('system.manage_all');
|
permissions.includes('system.manage_all');
|
||||||
|
|
||||||
if (!canCancel) {
|
if (!canCancel) {
|
||||||
throw new ForbiddenException(
|
throw new PermissionException('correspondence', 'cancel');
|
||||||
'Only administrators can cancel correspondences'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are any active circulations
|
// Check if there are any active circulations
|
||||||
@@ -981,7 +984,7 @@ export class CorrespondenceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!currentRevision) {
|
if (!currentRevision) {
|
||||||
throw new NotFoundException('Current revision not found');
|
throw new NotFoundException('Current revision');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get cancelled status
|
// Get cancelled status
|
||||||
@@ -990,7 +993,7 @@ export class CorrespondenceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!cancelledStatus) {
|
if (!cancelledStatus) {
|
||||||
throw new InternalServerErrorException('CANCELLED status not found');
|
throw new SystemException('CANCELLED status not found in Master Data');
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryRunner = this.dataSource.createQueryRunner();
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import {
|
|||||||
} from './dto';
|
} from './dto';
|
||||||
import { Project } from '../project/entities/project.entity';
|
import { Project } from '../project/entities/project.entity';
|
||||||
import { UserAssignment } from '../user/entities/user-assignment.entity';
|
import { UserAssignment } from '../user/entities/user-assignment.entity';
|
||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
NotFoundException,
|
||||||
|
PermissionException,
|
||||||
|
} from '../../common/exceptions';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
@@ -58,7 +61,7 @@ export class DashboardService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new NotFoundException(`Project with ID ${projectId} not found`);
|
throw new NotFoundException('Project', String(projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ตรวจสอบสิทธิ (UserAssignment)
|
// 2. ตรวจสอบสิทธิ (UserAssignment)
|
||||||
@@ -82,9 +85,7 @@ export class DashboardService {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`User ${userId} attempted to access project ${projectId} without assignment`
|
`User ${userId} attempted to access project ${projectId} without assignment`
|
||||||
);
|
);
|
||||||
throw new ForbiddenException(
|
throw new PermissionException('project', 'view');
|
||||||
`You do not have access to project ${projectId}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return project.id;
|
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 { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
|
||||||
import { Repository, EntityManager, IsNull, Equal } from 'typeorm';
|
import { Repository, EntityManager, IsNull, Equal } from 'typeorm';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -318,8 +319,11 @@ export class DocumentNumberingService {
|
|||||||
async setCounterValue(id: number, sequence: number) {
|
async setCounterValue(id: number, sequence: number) {
|
||||||
await Promise.resolve(id); // satisfy unused
|
await Promise.resolve(id); // satisfy unused
|
||||||
await Promise.resolve(sequence);
|
await Promise.resolve(sequence);
|
||||||
throw new BadRequestException(
|
throw new BusinessException(
|
||||||
'Updating counter by single ID is not supported with composite keys. Use manualOverride.'
|
'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
|
// File: src/modules/json-schema/json-schema.service.ts
|
||||||
// บันทึกการแก้ไข: Fix TS2345 (undefined check)
|
// บันทึกการแก้ไข: Fix TS2345 (undefined check)
|
||||||
|
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BusinessException,
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
OnModuleInit,
|
ValidationException,
|
||||||
} from '@nestjs/common';
|
} from '../../common/exceptions';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import Ajv, { ValidateFunction } from 'ajv';
|
import Ajv, { ValidateFunction } from 'ajv';
|
||||||
import addFormats from 'ajv-formats';
|
import addFormats from 'ajv-formats';
|
||||||
@@ -101,7 +100,7 @@ export class JsonSchemaService implements OnModuleInit {
|
|||||||
try {
|
try {
|
||||||
this.ajv.compile(createDto.schemaDefinition);
|
this.ajv.compile(createDto.schemaDefinition);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
`Invalid JSON Schema format: ${error instanceof Error ? error.message : String(error)}`
|
`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> {
|
async findOne(id: number): Promise<JsonSchema> {
|
||||||
const schema = await this.jsonSchemaRepository.findOne({ where: { id } });
|
const schema = await this.jsonSchemaRepository.findOne({ where: { id } });
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
throw new NotFoundException(`JsonSchema with ID ${id} not found`);
|
throw new NotFoundException('JsonSchema', String(id));
|
||||||
}
|
}
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
@@ -224,9 +223,7 @@ export class JsonSchemaService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('JsonSchema', `${code}@v${version}`);
|
||||||
`JsonSchema '${code}' version ${version} not found`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
@@ -241,9 +238,7 @@ export class JsonSchemaService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('Active JsonSchema', code);
|
||||||
`Active JsonSchema with code '${code}' not found`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
@@ -333,8 +328,10 @@ export class JsonSchemaService implements OnModuleInit {
|
|||||||
validate = this.ajv.compile(schema.schemaDefinition);
|
validate = this.ajv.compile(schema.schemaDefinition);
|
||||||
this.validators.set(schemaCode, validate);
|
this.validators.set(schemaCode, validate);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
throw new BadRequestException(
|
throw new BusinessException(
|
||||||
`Invalid Schema Definition for '${schemaCode}': ${error instanceof Error ? error.message : String(error)}`
|
'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
|
const errorMsg = result.errors
|
||||||
.map((e) => `${e.field}: ${e.message}`)
|
.map((e) => `${e.field}: ${e.message}`)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
throw new BadRequestException(`JSON Validation Failed: ${errorMsg}`);
|
throw new ValidationException(`JSON Validation Failed: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -372,7 +369,7 @@ export class JsonSchemaService implements OnModuleInit {
|
|||||||
try {
|
try {
|
||||||
this.ajv.compile(updateDto.schemaDefinition);
|
this.ajv.compile(updateDto.schemaDefinition);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
`Invalid JSON Schema format: ${error instanceof Error ? error.message : String(error)}`
|
`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
|
// 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 { DataSource } from 'typeorm';
|
||||||
import { JsonSchemaService } from '../json-schema.service';
|
import { JsonSchemaService } from '../json-schema.service';
|
||||||
|
|
||||||
@@ -66,9 +70,7 @@ export class SchemaMigrationService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!entities || entities.length === 0) {
|
if (!entities || entities.length === 0) {
|
||||||
throw new BadRequestException(
|
throw new NotFoundException(entityType, String(entityId));
|
||||||
`Entity ${entityType} with ID ${entityId} not found.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const entity = entities[0];
|
const entity = entities[0];
|
||||||
@@ -125,8 +127,10 @@ export class SchemaMigrationService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
throw new BadRequestException(
|
throw new BusinessException(
|
||||||
`Migration failed: Resulting data does not match target schema v${targetSchema.version}. Errors: ${JSON.stringify(validation.errors)}`
|
'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
|
// 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 {
|
import {
|
||||||
UiSchema,
|
UiSchema,
|
||||||
UiSchemaField,
|
UiSchemaField,
|
||||||
@@ -21,8 +22,8 @@ export class UiSchemaService {
|
|||||||
|
|
||||||
// 1. Validate Structure เบื้องต้น
|
// 1. Validate Structure เบื้องต้น
|
||||||
if (!uiSchema.layout || !uiSchema.fields) {
|
if (!uiSchema.layout || !uiSchema.fields) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'UI Schema must contain "layout" and "fields" properties.'
|
'UI Schema must contain "layout" and "fields" properties'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export class UiSchemaService {
|
|||||||
group.fields.forEach((fieldKey) => {
|
group.fields.forEach((fieldKey) => {
|
||||||
layoutFields.add(fieldKey);
|
layoutFields.add(fieldKey);
|
||||||
if (!definedFields.has(fieldKey)) {
|
if (!definedFields.has(fieldKey)) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
`Field "${fieldKey}" used in layout "${group.title}" is not defined in "fields".`
|
`Field "${fieldKey}" used in layout "${group.title}" is not defined in "fields".`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Injectable,
|
BusinessException,
|
||||||
Logger,
|
|
||||||
ConflictException,
|
ConflictException,
|
||||||
BadRequestException,
|
NotFoundException,
|
||||||
InternalServerErrorException,
|
SystemException,
|
||||||
} from '@nestjs/common';
|
ValidationException,
|
||||||
|
} from '../../common/exceptions';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import { ImportCorrespondenceDto } from './dto/import-correspondence.dto';
|
import { ImportCorrespondenceDto } from './dto/import-correspondence.dto';
|
||||||
@@ -57,7 +58,7 @@ export class MigrationService {
|
|||||||
userId: number
|
userId: number
|
||||||
) {
|
) {
|
||||||
if (!idempotencyKey) {
|
if (!idempotencyKey) {
|
||||||
throw new BadRequestException('Idempotency-Key header is required');
|
throw new ValidationException('Idempotency-Key header is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Idempotency Check
|
// 1. Idempotency Check
|
||||||
@@ -76,7 +77,10 @@ export class MigrationService {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new ConflictException(
|
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) {
|
if (!typeId) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
`Category "${dto.category}" not found in system.`
|
`Category "${dto.category}" not found in system`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +133,8 @@ export class MigrationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!status) {
|
if (!status) {
|
||||||
throw new InternalServerErrorException(
|
throw new SystemException(
|
||||||
'CRITICAL: No default correspondence status found (missing CLBOWN/DRAFT)'
|
'No default correspondence status found (missing CLBOWN/DRAFT)'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,9 +143,7 @@ export class MigrationService {
|
|||||||
where: { id: dto.projectId },
|
where: { id: dto.projectId },
|
||||||
});
|
});
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new BadRequestException(
|
throw new NotFoundException('Project', String(dto.projectId));
|
||||||
`Project ID ${dto.projectId} not found in database`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRFA = type?.typeCode === 'RFA' || dto.category === 'RFA';
|
const isRFA = type?.typeCode === 'RFA' || dto.category === 'RFA';
|
||||||
@@ -397,9 +399,7 @@ export class MigrationService {
|
|||||||
});
|
});
|
||||||
await this.importTransactionRepo.save(failedTransaction).catch(() => {});
|
await this.importTransactionRepo.save(failedTransaction).catch(() => {});
|
||||||
|
|
||||||
throw new InternalServerErrorException(
|
throw new SystemException('Migration import failed: ' + errorMessage);
|
||||||
'Migration import failed: ' + errorMessage
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
await queryRunner.release();
|
await queryRunner.release();
|
||||||
}
|
}
|
||||||
@@ -407,7 +407,7 @@ export class MigrationService {
|
|||||||
|
|
||||||
async enqueueRecord(dto: EnqueueMigrationDto) {
|
async enqueueRecord(dto: EnqueueMigrationDto) {
|
||||||
if (!dto.documentNumber) {
|
if (!dto.documentNumber) {
|
||||||
throw new BadRequestException('documentNumber is required');
|
throw new ValidationException('documentNumber is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine status based on confidence policy in ADR-017
|
// Determine status based on confidence policy in ADR-017
|
||||||
@@ -492,7 +492,7 @@ export class MigrationService {
|
|||||||
async getQueueItemById(id: number) {
|
async getQueueItemById(id: number) {
|
||||||
const item = await this.reviewQueueRepo.findOne({ where: { id } });
|
const item = await this.reviewQueueRepo.findOne({ where: { id } });
|
||||||
if (!item) {
|
if (!item) {
|
||||||
throw new BadRequestException(`Queue item with ID ${id} not found`);
|
throw new NotFoundException('Queue item', String(id));
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -538,12 +538,14 @@ export class MigrationService {
|
|||||||
) {
|
) {
|
||||||
const queueItem = await this.reviewQueueRepo.findOne({ where: { id } });
|
const queueItem = await this.reviewQueueRepo.findOne({ where: { id } });
|
||||||
if (!queueItem) {
|
if (!queueItem) {
|
||||||
throw new BadRequestException(`Queue item ${id} not found`);
|
throw new NotFoundException('Queue item', String(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queueItem.status !== MigrationReviewStatus.PENDING) {
|
if (queueItem.status !== MigrationReviewStatus.PENDING) {
|
||||||
throw new BadRequestException(
|
throw new BusinessException(
|
||||||
`Queue item ${id} is already ${queueItem.status}`
|
'MIGRATION_ITEM_NOT_PENDING',
|
||||||
|
`Queue item ${id} is already ${queueItem.status}`,
|
||||||
|
'รายการนี้ไม่อยู่ในสถานะ PENDING'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +567,7 @@ export class MigrationService {
|
|||||||
userId: number
|
userId: number
|
||||||
) {
|
) {
|
||||||
if (!idempotencyKey) {
|
if (!idempotencyKey) {
|
||||||
throw new BadRequestException('Idempotency-Key header is required');
|
throw new ValidationException('Idempotency-Key header is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -612,7 +614,7 @@ export class MigrationService {
|
|||||||
async rejectQueueItem(id: number, userId: number) {
|
async rejectQueueItem(id: number, userId: number) {
|
||||||
const queueItem = await this.reviewQueueRepo.findOne({ where: { id } });
|
const queueItem = await this.reviewQueueRepo.findOne({ where: { id } });
|
||||||
if (!queueItem) {
|
if (!queueItem) {
|
||||||
throw new BadRequestException('Queue item not found');
|
throw new NotFoundException('Queue item', String(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
queueItem.status = MigrationReviewStatus.REJECTED;
|
queueItem.status = MigrationReviewStatus.REJECTED;
|
||||||
@@ -628,12 +630,12 @@ export class MigrationService {
|
|||||||
|
|
||||||
getStagingFileStream(filePath: string) {
|
getStagingFileStream(filePath: string) {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
throw new BadRequestException('File path is required');
|
throw new ValidationException('File path is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPath = path.resolve(filePath);
|
const resolvedPath = path.resolve(filePath);
|
||||||
if (!existsSync(resolvedPath)) {
|
if (!existsSync(resolvedPath)) {
|
||||||
throw new BadRequestException('File not found at specified path');
|
throw new NotFoundException('File', filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return createReadStream(resolvedPath);
|
return createReadStream(resolvedPath);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
// File: src/modules/rfa/rfa.service.ts
|
// File: src/modules/rfa/rfa.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BusinessException,
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
InternalServerErrorException,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
PermissionException,
|
||||||
|
SystemException,
|
||||||
|
ValidationException,
|
||||||
|
WorkflowException,
|
||||||
|
} from '../../common/exceptions';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, In, Repository } from 'typeorm';
|
import { DataSource, In, Repository } from 'typeorm';
|
||||||
|
|
||||||
@@ -122,7 +123,8 @@ export class RfaService {
|
|||||||
const rfaType = await this.rfaTypeRepo.findOne({
|
const rfaType = await this.rfaTypeRepo.findOne({
|
||||||
where: { id: createDto.rfaTypeId },
|
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 rfaTypeCode = rfaType.typeCode.toUpperCase();
|
||||||
const rawShopDrawingRefs = createDto.shopDrawingRevisionIds ?? [];
|
const rawShopDrawingRefs = createDto.shopDrawingRevisionIds ?? [];
|
||||||
@@ -130,25 +132,25 @@ export class RfaService {
|
|||||||
|
|
||||||
if (['DDW', 'SDW'].includes(rfaTypeCode)) {
|
if (['DDW', 'SDW'].includes(rfaTypeCode)) {
|
||||||
if (rawShopDrawingRefs.length === 0) {
|
if (rawShopDrawingRefs.length === 0) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'Selected RFA Type requires at least one Shop Drawing Revision'
|
'Selected RFA Type requires at least one Shop Drawing Revision'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rawAsBuiltDrawingRefs.length > 0) {
|
if (rawAsBuiltDrawingRefs.length > 0) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'Selected RFA Type cannot reference As-Built Drawing Revisions'
|
'Selected RFA Type cannot reference As-Built Drawing Revisions'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (rfaTypeCode === 'ADW') {
|
} else if (rfaTypeCode === 'ADW') {
|
||||||
if (rawAsBuiltDrawingRefs.length === 0) {
|
if (rawAsBuiltDrawingRefs.length === 0) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'Selected RFA Type requires at least one As-Built Drawing Revision'
|
'Selected RFA Type requires at least one As-Built Drawing Revision'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rawShopDrawingRefs.length > 0) {
|
if (rawShopDrawingRefs.length > 0) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'Selected RFA Type cannot reference Shop Drawing Revisions'
|
'Selected RFA Type cannot reference Shop Drawing Revisions'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,7 +158,7 @@ export class RfaService {
|
|||||||
rawShopDrawingRefs.length > 0 ||
|
rawShopDrawingRefs.length > 0 ||
|
||||||
rawAsBuiltDrawingRefs.length > 0
|
rawAsBuiltDrawingRefs.length > 0
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'Selected RFA Type does not support drawing revision items'
|
'Selected RFA Type does not support drawing revision items'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -185,7 +187,7 @@ export class RfaService {
|
|||||||
where: { typeCode: 'RFA', isActive: true },
|
where: { typeCode: 'RFA', isActive: true },
|
||||||
});
|
});
|
||||||
if (!correspondenceType) {
|
if (!correspondenceType) {
|
||||||
throw new InternalServerErrorException(
|
throw new SystemException(
|
||||||
'Correspondence Type RFA not found in Master Data'
|
'Correspondence Type RFA not found in Master Data'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -195,8 +197,11 @@ export class RfaService {
|
|||||||
: rfaType.contractId;
|
: rfaType.contractId;
|
||||||
|
|
||||||
if (rfaType.contractId !== internalContractId) {
|
if (rfaType.contractId !== internalContractId) {
|
||||||
throw new BadRequestException(
|
throw new BusinessException(
|
||||||
'Selected RFA Type does not belong to the selected contract'
|
'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) {
|
if (!discipline) {
|
||||||
throw new NotFoundException('Discipline not found');
|
throw new NotFoundException(
|
||||||
|
'Discipline',
|
||||||
|
String(createDto.disciplineId)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (discipline.contractId !== internalContractId) {
|
if (discipline.contractId !== internalContractId) {
|
||||||
throw new BadRequestException(
|
throw new BusinessException(
|
||||||
'Selected Discipline does not belong to the selected contract'
|
'DISCIPLINE_CONTRACT_MISMATCH',
|
||||||
|
'Selected Discipline does not belong to the selected contract',
|
||||||
|
'Discipline ที่เลือกไม่ตรงกับสัญญาที่ระบุ',
|
||||||
|
['เลือก Discipline ที่ตรงกับสัญญา']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,9 +237,7 @@ export class RfaService {
|
|||||||
where: { statusCode: 'DFT' },
|
where: { statusCode: 'DFT' },
|
||||||
});
|
});
|
||||||
if (!statusDraft) {
|
if (!statusDraft) {
|
||||||
throw new InternalServerErrorException(
|
throw new SystemException('Status DFT (Draft) not found in Master Data');
|
||||||
'Status DFT (Draft) not found in Master Data'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedOriginatorId = createDto.originatorId
|
const resolvedOriginatorId = createDto.originatorId
|
||||||
@@ -247,15 +256,18 @@ export class RfaService {
|
|||||||
user.user_id
|
user.user_id
|
||||||
);
|
);
|
||||||
if (!canManageAll) {
|
if (!canManageAll) {
|
||||||
throw new ForbiddenException(
|
throw new PermissionException(
|
||||||
'You do not have permission to create documents on behalf of other organizations.'
|
'rfa',
|
||||||
|
'create on behalf of other organization'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
userOrgId = resolvedOriginatorId;
|
userOrgId = resolvedOriginatorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userOrgId) {
|
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
|
// EC-RFA-001: Check for existing active RFA per Shop Drawing Revision
|
||||||
@@ -273,9 +285,11 @@ export class RfaService {
|
|||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
if (conflictingItems.length > 0) {
|
if (conflictingItems.length > 0) {
|
||||||
throw new BadRequestException(
|
throw new BusinessException(
|
||||||
'[EC-RFA-001] One or more selected Shop Drawing Revisions already have an active RFA. ' +
|
'EC_RFA_001_ACTIVE_RFA_EXISTS',
|
||||||
'A Shop Drawing Revision can only be referenced by one active RFA at a time.'
|
'[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)
|
if (!corrStatusDraft)
|
||||||
throw new InternalServerErrorException(
|
throw new SystemException(
|
||||||
'Correspondence Status DRAFT not found'
|
'Correspondence Status DRAFT not found in Master Data'
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. Create Correspondence Record
|
// 1. Create Correspondence Record
|
||||||
@@ -385,7 +399,7 @@ export class RfaService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (shopDrawings.length !== shopDrawingRevisionIds.length) {
|
if (shopDrawings.length !== shopDrawingRevisionIds.length) {
|
||||||
throw new NotFoundException('Some Shop Drawing Revisions not found');
|
throw new NotFoundException('Shop Drawing Revision');
|
||||||
}
|
}
|
||||||
|
|
||||||
rfaItems.push(
|
rfaItems.push(
|
||||||
@@ -405,9 +419,7 @@ export class RfaService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (asBuiltDrawings.length !== asBuiltDrawingRevisionIds.length) {
|
if (asBuiltDrawings.length !== asBuiltDrawingRevisionIds.length) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('As-Built Drawing Revision');
|
||||||
'Some As-Built Drawing Revisions not found'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rfaItems.push(
|
rfaItems.push(
|
||||||
@@ -588,7 +600,7 @@ export class RfaService {
|
|||||||
select: ['id'],
|
select: ['id'],
|
||||||
});
|
});
|
||||||
if (!correspondence) {
|
if (!correspondence) {
|
||||||
throw new NotFoundException(`RFA with publicId ${publicId} not found`);
|
throw new NotFoundException('RFA', publicId);
|
||||||
}
|
}
|
||||||
return this.findOne(correspondence.id);
|
return this.findOne(correspondence.id);
|
||||||
}
|
}
|
||||||
@@ -599,7 +611,7 @@ export class RfaService {
|
|||||||
select: ['id'],
|
select: ['id'],
|
||||||
});
|
});
|
||||||
if (!correspondence) {
|
if (!correspondence) {
|
||||||
throw new NotFoundException(`RFA with publicId ${publicId} not found`);
|
throw new NotFoundException('RFA', publicId);
|
||||||
}
|
}
|
||||||
return this.findOne(correspondence.id, true);
|
return this.findOne(correspondence.id, true);
|
||||||
}
|
}
|
||||||
@@ -628,7 +640,7 @@ export class RfaService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!rfa) {
|
if (!rfa) {
|
||||||
throw new NotFoundException(`RFA ID ${id} not found`);
|
throw new NotFoundException('RFA', String(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rawEntities) {
|
if (rawEntities) {
|
||||||
@@ -657,12 +669,17 @@ export class RfaService {
|
|||||||
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
||||||
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
|
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
|
||||||
if (!currentCorrRev || !currentCorrRev.rfaRevision)
|
if (!currentCorrRev || !currentCorrRev.rfaRevision)
|
||||||
throw new NotFoundException('Current revision not found');
|
throw new NotFoundException('Current revision');
|
||||||
|
|
||||||
const currentRfaRev = currentCorrRev.rfaRevision;
|
const currentRfaRev = currentCorrRev.rfaRevision;
|
||||||
|
|
||||||
if (currentRfaRev.statusCode.statusCode !== 'DFT') {
|
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({
|
const template = await this.templateRepo.findOne({
|
||||||
@@ -671,7 +688,12 @@ export class RfaService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!template) {
|
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
|
// Manual fetch of steps
|
||||||
@@ -681,14 +703,19 @@ export class RfaService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (steps.length === 0) {
|
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({
|
const statusForApprove = await this.rfaStatusRepo.findOne({
|
||||||
where: { statusCode: 'FAP' },
|
where: { statusCode: 'FAP' },
|
||||||
});
|
});
|
||||||
if (!statusForApprove)
|
if (!statusForApprove)
|
||||||
throw new InternalServerErrorException('Status FAP not found');
|
throw new SystemException('Status FAP not found in Master Data');
|
||||||
|
|
||||||
const queryRunner = this.dataSource.createQueryRunner();
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
await queryRunner.connect();
|
await queryRunner.connect();
|
||||||
@@ -765,11 +792,14 @@ export class RfaService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!currentRouting)
|
if (!currentRouting)
|
||||||
throw new BadRequestException('No active workflow step found');
|
throw new WorkflowException(
|
||||||
if (currentRouting.toOrganizationId !== user.primaryOrganizationId) {
|
'NO_ACTIVE_WORKFLOW_STEP',
|
||||||
throw new ForbiddenException(
|
'No active workflow step found',
|
||||||
'You are not authorized to process this step'
|
'ไม่พบขั้นตอน Workflow ที่ยังเปิดอยู่',
|
||||||
|
['ตรวจสอบสถานะ Workflow ของเอกสาร']
|
||||||
);
|
);
|
||||||
|
if (currentRouting.toOrganizationId !== user.primaryOrganizationId) {
|
||||||
|
throw new PermissionException('rfa workflow step', 'process');
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await this.templateRepo.findOne({
|
const template = await this.templateRepo.findOne({
|
||||||
@@ -777,7 +807,10 @@ export class RfaService {
|
|||||||
// relations: ['steps'],
|
// 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
|
// Manual fetch steps
|
||||||
const steps = await this.templateStepRepo.find({
|
const steps = await this.templateStepRepo.find({
|
||||||
@@ -786,7 +819,7 @@ export class RfaService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (steps.length === 0)
|
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
|
// Call Engine to calculate next step
|
||||||
const result = this.workflowEngine.processAction(
|
const result = this.workflowEngine.processAction(
|
||||||
@@ -874,13 +907,16 @@ export class RfaService {
|
|||||||
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
||||||
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
|
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
|
||||||
if (!currentCorrRev || !currentCorrRev.rfaRevision)
|
if (!currentCorrRev || !currentCorrRev.rfaRevision)
|
||||||
throw new NotFoundException('Current revision not found');
|
throw new NotFoundException('Current revision');
|
||||||
|
|
||||||
const currentRfaRev = currentCorrRev.rfaRevision;
|
const currentRfaRev = currentCorrRev.rfaRevision;
|
||||||
|
|
||||||
if (currentRfaRev.statusCode.statusCode !== 'DFT') {
|
if (currentRfaRev.statusCode.statusCode !== 'DFT') {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
'Only DRAFT documents can be edited. Submit a new revision for non-draft documents.'
|
'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) ?? [];
|
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
||||||
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
|
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
|
||||||
if (!currentCorrRev || !currentCorrRev.rfaRevision)
|
if (!currentCorrRev || !currentCorrRev.rfaRevision)
|
||||||
throw new NotFoundException('Current revision not found');
|
throw new NotFoundException('Current revision');
|
||||||
|
|
||||||
const currentRfaRev = currentCorrRev.rfaRevision;
|
const currentRfaRev = currentCorrRev.rfaRevision;
|
||||||
|
|
||||||
if (currentRfaRev.statusCode.statusCode !== 'DFT') {
|
if (currentRfaRev.statusCode.statusCode !== 'DFT') {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
'Only DRAFT documents can be cancelled. Contact an Org Admin to cancel submitted documents.'
|
'RFA_CANCEL_NON_DRAFT',
|
||||||
|
'Only DRAFT documents can be cancelled',
|
||||||
|
'สามารถยกเลิกได้เฉพาะเอกสารสถานะ DRAFT เท่านั้น',
|
||||||
|
['ติดต่อ Org Admin เพื่อยกเลิกเอกสารที่ส่งแล้ว']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,7 +968,7 @@ export class RfaService {
|
|||||||
where: { statusCode: 'CC' },
|
where: { statusCode: 'CC' },
|
||||||
});
|
});
|
||||||
if (!statusCC)
|
if (!statusCC)
|
||||||
throw new InternalServerErrorException(
|
throw new SystemException(
|
||||||
'Status CC (Cancelled) not found in Master Data'
|
'Status CC (Cancelled) not found in Master Data'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
InternalServerErrorException,
|
PermissionException,
|
||||||
BadRequestException,
|
SystemException,
|
||||||
ForbiddenException,
|
ValidationException,
|
||||||
} from '@nestjs/common';
|
} from '../../common/exceptions';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import { Transmittal } from './entities/transmittal.entity';
|
import { Transmittal } from './entities/transmittal.entity';
|
||||||
@@ -57,13 +56,13 @@ export class TransmittalService {
|
|||||||
const type = await this.typeRepo.findOne({
|
const type = await this.typeRepo.findOne({
|
||||||
where: { typeCode: 'TRN' }, // Adjust code as per Master Data
|
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({
|
const statusDraft = await this.statusRepo.findOne({
|
||||||
where: { statusCode: 'DRAFT' },
|
where: { statusCode: 'DRAFT' },
|
||||||
});
|
});
|
||||||
if (!statusDraft)
|
if (!statusDraft)
|
||||||
throw new InternalServerErrorException('Status DRAFT not found');
|
throw new SystemException('Status DRAFT not found in Master Data');
|
||||||
|
|
||||||
const queryRunner = this.dataSource.createQueryRunner();
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
await queryRunner.connect();
|
await queryRunner.connect();
|
||||||
@@ -86,15 +85,16 @@ export class TransmittalService {
|
|||||||
user.user_id
|
user.user_id
|
||||||
);
|
);
|
||||||
if (!canManageAll) {
|
if (!canManageAll) {
|
||||||
throw new ForbiddenException(
|
throw new PermissionException(
|
||||||
'You do not have permission to create documents on behalf of other organizations.'
|
'transmittal',
|
||||||
|
'create on behalf of other organization'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
userOrgId = resolvedOriginatorId;
|
userOrgId = resolvedOriginatorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userOrgId) {
|
if (!userOrgId) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'User must belong to an organization to create a transmittal'
|
'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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import { UserAssignment } from './entities/user-assignment.entity';
|
import { UserAssignment } from './entities/user-assignment.entity';
|
||||||
@@ -22,7 +23,7 @@ export class UserAssignmentService {
|
|||||||
(v) => v != null
|
(v) => v != null
|
||||||
);
|
);
|
||||||
if (scopes.length > 1) {
|
if (scopes.length > 1) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'Cannot assign multiple scopes at once. Choose one of Org, Project, or Contract.'
|
'Cannot assign multiple scopes at once. Choose one of Org, Project, or Contract.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -55,7 +56,7 @@ export class UserAssignmentService {
|
|||||||
// Validation (Scope)
|
// Validation (Scope)
|
||||||
const scopes = [organizationId, projectId].filter((v) => v != null);
|
const scopes = [organizationId, projectId].filter((v) => v != null);
|
||||||
if (scopes.length > 1) {
|
if (scopes.length > 1) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
`User ${userId}: Cannot assign multiple scopes.`
|
`User ${userId}: Cannot assign multiple scopes.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
// File: src/modules/user/user.service.ts
|
// File: src/modules/user/user.service.ts
|
||||||
// บันทึกการแก้ไข: แก้ไข Error TS1272 โดยใช้ 'import type' สำหรับ Cache interface (T1.3)
|
// บันทึกการแก้ไข: แก้ไข Error TS1272 โดยใช้ 'import type' สำหรับ Cache interface (T1.3)
|
||||||
|
|
||||||
import {
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
Injectable,
|
import { NotFoundException, ConflictException } from '../../common/exceptions';
|
||||||
NotFoundException,
|
|
||||||
ConflictException,
|
|
||||||
Inject,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
@@ -56,7 +52,12 @@ export class UserService {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const dbError = error as { code?: string };
|
const dbError = error as { code?: string };
|
||||||
if (dbError.code === 'ER_DUP_ENTRY') {
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -152,7 +153,7 @@ export class UserService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException(`User with ID ${id} not found`);
|
throw new NotFoundException('User', String(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
@@ -171,7 +172,7 @@ export class UserService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException(`User with publicId ${publicId} not found`);
|
throw new NotFoundException('User', publicId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
@@ -217,7 +218,7 @@ export class UserService {
|
|||||||
const result = await this.usersRepository.softDelete(user.user_id);
|
const result = await this.usersRepository.softDelete(user.user_id);
|
||||||
|
|
||||||
if (result.affected === 0) {
|
if (result.affected === 0) {
|
||||||
throw new NotFoundException(`User with UUID ${uuid} not found`);
|
throw new NotFoundException('User', uuid);
|
||||||
}
|
}
|
||||||
// เคลียร์ Cache เมื่อลบ
|
// เคลียร์ Cache เมื่อลบ
|
||||||
await this.clearUserCache(user.user_id);
|
await this.clearUserCache(user.user_id);
|
||||||
@@ -275,7 +276,7 @@ export class UserService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
throw new NotFoundException(`Role ID ${roleId} not found`);
|
throw new NotFoundException('Role', String(roleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load permissions entities
|
// 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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { WorkflowDslSchema, WorkflowDsl } from './workflow-dsl.schema';
|
import { WorkflowDslSchema, WorkflowDsl } from './workflow-dsl.schema';
|
||||||
@@ -36,16 +41,18 @@ export class WorkflowDslParser {
|
|||||||
return await this.workflowDefRepo.save(definition);
|
return await this.workflowDefRepo.save(definition);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof SyntaxError) {
|
if (error instanceof SyntaxError) {
|
||||||
throw new BadRequestException(`Invalid JSON: ${error.message}`);
|
throw new ValidationException(`Invalid JSON: ${error.message}`);
|
||||||
}
|
}
|
||||||
const err = error as {
|
const err = error as {
|
||||||
name?: string;
|
name?: string;
|
||||||
errors?: unknown;
|
errors?: unknown[];
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
if (err.name === 'ZodError') {
|
if (err.name === 'ZodError') {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Invalid workflow DSL: ${JSON.stringify(err.errors)}`
|
'INVALID_WORKFLOW_DSL',
|
||||||
|
`Invalid workflow DSL: ${JSON.stringify(err.errors)}`,
|
||||||
|
'Workflow DSL ไม่ถูกต้อง'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -66,16 +73,20 @@ export class WorkflowDslParser {
|
|||||||
|
|
||||||
// 1. Validate initial state
|
// 1. Validate initial state
|
||||||
if (!stateSet.has(dsl.initialState)) {
|
if (!stateSet.has(dsl.initialState)) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Initial state "${dsl.initialState}" not found in states array`
|
'DSL_INVALID_INITIAL_STATE',
|
||||||
|
`Initial state "${dsl.initialState}" not found in states array`,
|
||||||
|
'Initial State ไม่พบใน States Array'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Validate final states
|
// 2. Validate final states
|
||||||
dsl.finalStates.forEach((state) => {
|
dsl.finalStates.forEach((state) => {
|
||||||
if (!stateSet.has(state)) {
|
if (!stateSet.has(state)) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Final state "${state}" not found in states array`
|
'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) => {
|
dsl.transitions.forEach((transition, index) => {
|
||||||
// Check 'from' state
|
// Check 'from' state
|
||||||
if (!stateSet.has(transition.from)) {
|
if (!stateSet.has(transition.from)) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Transition ${index}: 'from' state "${transition.from}" not found in states array`
|
'DSL_INVALID_TRANSITION_FROM',
|
||||||
|
`Transition ${index}: 'from' state "${transition.from}" not found in states array`,
|
||||||
|
'Transition อ้างอิง State ที่ไม่พบ'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 'to' state
|
// Check 'to' state
|
||||||
if (!stateSet.has(transition.to)) {
|
if (!stateSet.has(transition.to)) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Transition ${index}: 'to' state "${transition.to}" not found in states array`
|
'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) => {
|
dsl.transitions.forEach((transition) => {
|
||||||
const key = `${transition.from}-${transition.trigger}-${transition.to}`;
|
const key = `${transition.from}-${transition.trigger}-${transition.to}`;
|
||||||
if (transitionKeys.has(key)) {
|
if (transitionKeys.has(key)) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Duplicate transition: ${transition.from} --[${transition.trigger}]--> ${transition.to}`
|
'DSL_DUPLICATE_TRANSITION',
|
||||||
|
`Duplicate transition: ${transition.from} --[${transition.trigger}]--> ${transition.to}`,
|
||||||
|
'DSL มี Transition ซ้ำซ้อน'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
transitionKeys.add(key);
|
transitionKeys.add(key);
|
||||||
@@ -158,9 +175,7 @@ export class WorkflowDslParser {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
throw new BadRequestException(
|
throw new NotFoundException('Workflow definition', String(definitionId));
|
||||||
`Workflow definition ${definitionId} not found`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -171,8 +186,10 @@ export class WorkflowDslParser {
|
|||||||
`Failed to parse stored DSL for definition ${definitionId}`,
|
`Failed to parse stored DSL for definition ${definitionId}`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Invalid stored DSL: ${error instanceof Error ? error.message : String(error)}`
|
'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
|
// 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)
|
// 1. Interfaces for RAW DSL (Input from User)
|
||||||
@@ -86,8 +90,11 @@ export class WorkflowDslService {
|
|||||||
for (const rawState of dsl.states) {
|
for (const rawState of dsl.states) {
|
||||||
if (rawState.initial) {
|
if (rawState.initial) {
|
||||||
if (initialFound) {
|
if (initialFound) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`DSL Error: Multiple initial states found (at "${rawState.name}").`
|
'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;
|
compiled.initialState = rawState.name;
|
||||||
@@ -104,8 +111,11 @@ export class WorkflowDslService {
|
|||||||
for (const [action, rule] of Object.entries(rawState.on)) {
|
for (const [action, rule] of Object.entries(rawState.on)) {
|
||||||
// Validation: Target state must exist
|
// Validation: Target state must exist
|
||||||
if (!definedStates.has(rule.to)) {
|
if (!definedStates.has(rule.to)) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`DSL Error: State "${rawState.name}" transitions via "${action}" to unknown state "${rule.to}".`
|
'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) {
|
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;
|
return compiled;
|
||||||
@@ -153,15 +168,21 @@ export class WorkflowDslService {
|
|||||||
|
|
||||||
// 1. Validate State Existence
|
// 1. Validate State Existence
|
||||||
if (!stateConfig) {
|
if (!stateConfig) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Runtime Error: Current state "${currentState}" is invalid.`
|
'WORKFLOW_INVALID_CURRENT_STATE',
|
||||||
|
`Runtime Error: Current state "${currentState}" is invalid`,
|
||||||
|
'Workflow อยู่ในสถานะที่ไม่รู้จัก',
|
||||||
|
['ตรวจสอบ DSL ของ Workflow']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check if terminal
|
// 2. Check if terminal
|
||||||
if (stateConfig.terminal) {
|
if (stateConfig.terminal) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Runtime Error: Cannot transition from terminal state "${currentState}".`
|
'WORKFLOW_TERMINAL_STATE',
|
||||||
|
`Runtime Error: Cannot transition from terminal state "${currentState}"`,
|
||||||
|
'ไม่สามารถดำเนินการจาก State สุดท้ายได้',
|
||||||
|
['เอกสารสิ้นสุดกระบวนการแล้ว']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +190,11 @@ export class WorkflowDslService {
|
|||||||
const transition = stateConfig.transitions[action];
|
const transition = stateConfig.transitions[action];
|
||||||
if (!transition) {
|
if (!transition) {
|
||||||
const allowed = Object.keys(stateConfig.transitions).join(', ');
|
const allowed = Object.keys(stateConfig.transitions).join(', ');
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Invalid Action: "${action}" is not allowed from "${currentState}". Allowed: [${allowed}]`
|
'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) {
|
if (transition.condition) {
|
||||||
const isMet = this.evaluateCondition(transition.condition, context);
|
const isMet = this.evaluateCondition(transition.condition, context);
|
||||||
if (!isMet) {
|
if (!isMet) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
'Condition Failed: The criteria for this transition are not met.'
|
'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) {
|
private validateSchemaStructure(dsl: unknown) {
|
||||||
if (!dsl || typeof dsl !== 'object') {
|
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>;
|
const d = dsl as Record<string, unknown>;
|
||||||
if (!d.workflow || !d.states || !Array.isArray(d.states)) {
|
if (!d.workflow || !d.states || !Array.isArray(d.states)) {
|
||||||
throw new BadRequestException(
|
throw new ValidationException(
|
||||||
'DSL Error: Missing required fields (workflow, states).'
|
'DSL Error: Missing required fields (workflow, states)'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,15 +253,23 @@ export class WorkflowDslService {
|
|||||||
if (requiredRoles.length > 0) {
|
if (requiredRoles.length > 0) {
|
||||||
const hasRole = requiredRoles.some((r) => userRoles.includes(r));
|
const hasRole = requiredRoles.some((r) => userRoles.includes(r));
|
||||||
if (!hasRole) {
|
if (!hasRole) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Access Denied: Required roles [${requiredRoles.join(', ')}]`
|
'WORKFLOW_ROLE_REQUIRED',
|
||||||
|
`Access Denied: Required roles [${requiredRoles.join(', ')}]`,
|
||||||
|
`ต้องมี Role: [${requiredRoles.join(', ')}] จึงจะดำเนินการนี้ได้`,
|
||||||
|
['ติดต่อผู้ดูแลระบบเพื่อขอสิทธิ์']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Specific User
|
// Check Specific User
|
||||||
if (req.userId && String(req.userId) !== String(userId)) {
|
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
|
// File: src/modules/workflow-engine/workflow-engine.service.ts
|
||||||
|
|
||||||
import {
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
BadRequestException,
|
import { NotFoundException, WorkflowException } from '../../common/exceptions';
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
|
||||||
// Entities
|
// Entities
|
||||||
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
||||||
import { WorkflowHistory } from './entities/workflow-history.entity';
|
import { WorkflowHistory } from './entities/workflow-history.entity';
|
||||||
@@ -104,9 +99,7 @@ export class WorkflowEngineService {
|
|||||||
): Promise<WorkflowDefinition> {
|
): Promise<WorkflowDefinition> {
|
||||||
const definition = await this.workflowDefRepo.findOne({ where: { id } });
|
const definition = await this.workflowDefRepo.findOne({ where: { id } });
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('Workflow Definition', id);
|
||||||
`Workflow Definition with ID "${id}" not found`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.dsl) {
|
if (dto.dsl) {
|
||||||
@@ -115,8 +108,11 @@ export class WorkflowEngineService {
|
|||||||
definition.dsl = dto.dsl as unknown as Record<string, unknown>;
|
definition.dsl = dto.dsl as unknown as Record<string, unknown>;
|
||||||
definition.compiled = compiled as unknown as Record<string, unknown>;
|
definition.compiled = compiled as unknown as Record<string, unknown>;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Invalid DSL: ${error instanceof Error ? error.message : String(error)}`
|
'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> {
|
async getDefinitionById(id: string): Promise<WorkflowDefinition> {
|
||||||
const definition = await this.workflowDefRepo.findOne({ where: { id } });
|
const definition = await this.workflowDefRepo.findOne({ where: { id } });
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('Workflow Definition', id);
|
||||||
`Workflow Definition with ID "${id}" not found`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return definition;
|
return definition;
|
||||||
}
|
}
|
||||||
@@ -197,9 +191,7 @@ export class WorkflowEngineService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('Workflow', workflowCode);
|
||||||
`Workflow "${workflowCode}" not found or inactive.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. หา Initial State จาก Compiled Structure
|
// 2. หา Initial State จาก Compiled Structure
|
||||||
@@ -209,8 +201,11 @@ export class WorkflowEngineService {
|
|||||||
const initialState = compiled.initialState;
|
const initialState = compiled.initialState;
|
||||||
|
|
||||||
if (!initialState) {
|
if (!initialState) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Workflow "${workflowCode}" has no initial state defined.`
|
'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) {
|
if (!instance) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('Workflow Instance', instanceId);
|
||||||
`Workflow Instance "${instanceId}" not found`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return instance;
|
return instance;
|
||||||
@@ -276,14 +269,15 @@ export class WorkflowEngineService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('Workflow Instance', instanceId);
|
||||||
`Workflow Instance "${instanceId}" not found.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instance.status !== WorkflowStatus.ACTIVE) {
|
if (instance.status !== WorkflowStatus.ACTIVE) {
|
||||||
throw new BadRequestException(
|
throw new WorkflowException(
|
||||||
`Workflow is not active (Status: ${instance.status}).`
|
'WORKFLOW_NOT_ACTIVE',
|
||||||
|
`Workflow is not active (Status: ${instance.status})`,
|
||||||
|
'Workflow ไม่อยู่ในสถานะ Active',
|
||||||
|
['ตรวจสอบสถานะของ Workflow']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +421,12 @@ export class WorkflowEngineService {
|
|||||||
case 'RETURN': {
|
case 'RETURN': {
|
||||||
const targetStep = returnToSequence || currentSequence - 1;
|
const targetStep = returnToSequence || currentSequence - 1;
|
||||||
if (targetStep < 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 {
|
return {
|
||||||
nextStepSequence: targetStep,
|
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
|
// 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(
|
apiClient.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
return 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
|
### Architecture Decision Records
|
||||||
- **[ADR-017: Ollama Data Migration](./ADR-017-ollama-data-migration.md)** — Foundation migration architecture
|
- **[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-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)
|
- **[ADR-019: Hybrid Identifier Strategy](./ADR-019-hybrid-identifier-strategy.md)** — UUID usage patterns (CRITICAL)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user