690406:2310 Done Task BE-ERR-01
CI / CD Pipeline / build (push) Failing after 4m53s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-04-06 23:10:56 +07:00
parent c95e0f537e
commit 961ee72343
24 changed files with 1329 additions and 268 deletions
+2 -2
View File
@@ -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,
['ลองใหม่ภายหลัง', 'แจ้งผู้ดูแลระบบหากยังพบปัญหา']
);
}
}
+19
View File
@@ -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);
+93 -54
View File
@@ -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.`
); );
} }
+12 -11
View File
@@ -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,
+192
View File
@@ -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: ['ตรวจสอบการเชื่อมต่ออินเทอร์เน็ต', 'ลองใหม่ภายหลัง'],
},
};
}
+75 -1
View File
@@ -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)