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,