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