1160 lines
39 KiB
Markdown
1160 lines
39 KiB
Markdown
# รายงานการตรวจสอบ Unhandled Exception และ Crash Loop
|
|
## Batch 11: Controllers 101-110
|
|
|
|
**วันที่ตรวจสอบ:** 2026-05-08
|
|
**จำนวน Controllers ที่ตรวจสอบ:** 10 Controllers
|
|
|
|
---
|
|
|
|
## Controllers ที่ตรวจสอบในชุดนี้
|
|
|
|
1. [ProfileSalaryTempController.ts](src/controllers/ProfileSalaryTempController.ts)
|
|
2. [ReportController.ts](src/controllers/ReportController.ts) - เกินขนาด 256KB (skip)
|
|
3. [ScriptProfileOrgController.ts](src/controllers/ScriptProfileOrgController.ts)
|
|
4. [WorkflowController.ts](src/controllers/WorkflowController.ts)
|
|
5. [ProfileTrainingController.ts](src/controllers/ProfileTrainingController.ts)
|
|
6. [OrganizationController.ts](src/controllers/OrganizationController.ts) - ตรวจสอบแล้วใน batch ก่อนหน้า
|
|
7. [PositionController.ts](src/controllers/PositionController.ts) - ตรวจสอบแล้วใน batch ก่อนหน้า
|
|
8. [ProfileController.ts](src/controllers/ProfileController.ts) - ตรวจสอบแล้วใน batch ก่อนหน้า
|
|
9. [CommandController.ts](src/controllers/CommandController.ts) - ตรวจสอบแล้วใน batch ก่อนหน้า
|
|
10. [User Controller.ts](src/controllers/UserController.ts) - ตรวจสอบแล้วใน batch ก่อนหน้า
|
|
|
|
---
|
|
|
|
## รายการปัญหาที่พบ
|
|
|
|
### 1. 🔴 CRITICAL - ProfileSalaryTempController.ts - Unhandled Exception in Large Loop
|
|
|
|
**File & Location:** [ProfileSalaryTempController.ts](src/controllers/ProfileSalaryTempController.ts:1725-1747) - `changeSortEditGenAll()` method
|
|
|
|
**Problem Type:** 1. Unhandled Exception / 2. Missing Error Handle
|
|
|
|
**Root Cause:**
|
|
```typescript
|
|
@Get("change/sort/all")
|
|
public async changeSortEditGenAll() {
|
|
try {
|
|
const profiles = await this.profileRepo.find();
|
|
let num = 1;
|
|
for await (const item of profiles) {
|
|
let salaryOld = await this.salaryOldRepo.find({
|
|
where: { profileId: item.id },
|
|
order: { commandDateAffect: "ASC", order: "ASC" },
|
|
});
|
|
|
|
salaryOld.forEach((item: any, i) => {
|
|
item.order = i + 1;
|
|
});
|
|
num = num + 1;
|
|
console.log(num);
|
|
await this.salaryOldRepo.save(salaryOld);
|
|
}
|
|
|
|
return new HttpSuccess();
|
|
} catch {
|
|
throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่สามารถดำเนินการได้");
|
|
}
|
|
}
|
|
```
|
|
|
|
**ปัญหาที่พบ:**
|
|
1. **Loading all profiles at once** - `await this.profileRepo.find()` โหลดข้อมูลทั้งหมดเข้า memory อาจทำให้เกิด Out of Memory
|
|
2. **No transaction management** - แต่ละรอบบันทึกแยกกัน หากเกิด error ข้อมูลบางส่วนอาจถูกบันทึกแล้วบางส่วนไม่ได้
|
|
3. **No error handling per iteration** - หากเกิด error ใน loop ที่ profile ใด profile หนึ่ง ทั้งกระบวนการจะหยุดทันที
|
|
4. **Generic catch block** - catch แล้ว throw generic error โดยไม่ log รายละเอียดของ error ทำให้ debug ยาก
|
|
5. **No timeout protection** - หากมี profiles จำนวนมาก อาจทำให้ request ค้างนานเกินไป
|
|
|
|
**Recommended Fix:**
|
|
```typescript
|
|
@Get("change/sort/all")
|
|
public async changeSortEditGenAll() {
|
|
const queryRunner = AppDataSource.createQueryRunner();
|
|
await queryRunner.connect();
|
|
await queryRunner.startTransaction();
|
|
|
|
let processedCount = 0;
|
|
let failedCount = 0;
|
|
const errors: Array<{profileId: string, error: string}> = [];
|
|
|
|
try {
|
|
const BATCH_SIZE = 500;
|
|
let offset = 0;
|
|
let hasMore = true;
|
|
|
|
while (hasMore) {
|
|
const profiles = await queryRunner.manager.find(Profile, {
|
|
select: ["id"],
|
|
take: BATCH_SIZE,
|
|
skip: offset,
|
|
order: { id: 'ASC' }
|
|
});
|
|
|
|
if (profiles.length === 0) {
|
|
hasMore = false;
|
|
break;
|
|
}
|
|
|
|
for (const profile of profiles) {
|
|
try {
|
|
const salaryOld = await queryRunner.manager.find(ProfileSalary, {
|
|
where: { profileId: profile.id },
|
|
order: { commandDateAffect: "ASC", order: "ASC" },
|
|
});
|
|
|
|
// Update order
|
|
for (let i = 0; i < salaryOld.length; i++) {
|
|
salaryOld[i].order = i + 1;
|
|
}
|
|
|
|
await queryRunner.manager.save(salaryOld);
|
|
processedCount++;
|
|
|
|
if (processedCount % 100 === 0) {
|
|
console.log(`Processed ${processedCount} profiles`);
|
|
}
|
|
} catch (itemError: any) {
|
|
failedCount++;
|
|
errors.push({
|
|
profileId: profile.id,
|
|
error: itemError?.message || String(itemError)
|
|
});
|
|
console.error(`Failed to process profile ${profile.id}:`, itemError);
|
|
}
|
|
}
|
|
|
|
// Commit per batch to avoid large transaction
|
|
await queryRunner.commitTransaction();
|
|
await queryRunner.startTransaction();
|
|
|
|
offset += BATCH_SIZE;
|
|
}
|
|
|
|
await queryRunner.commitTransaction();
|
|
|
|
return new HttpSuccess({
|
|
message: "ปรับปรุงลำดับเสร็จสิ้น",
|
|
processed: processedCount,
|
|
failed: failedCount,
|
|
errors: errors.slice(0, 100) // ส่งเฉพาะ 100 errors แรก
|
|
});
|
|
|
|
} catch (error: any) {
|
|
await queryRunner.rollbackTransaction();
|
|
console.error("changeSortEditGenAll failed:", error);
|
|
throw new HttpError(
|
|
HttpStatusCode.INTERNAL_SERVER_ERROR,
|
|
`ไม่สามารถดำเนินการได้: ${error?.message || 'Unknown error'}`
|
|
);
|
|
} finally {
|
|
await queryRunner.release();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. 🔴 CRITICAL - ScriptProfileOrgController.ts - Unhandled External API Error
|
|
|
|
**File & Location:** [ScriptProfileOrgController.ts](src/controllers/ScriptProfileOrgController.ts:184-190) - `cronjobUpdateOrg()` method
|
|
|
|
**Problem Type:** 1. Unhandled Exception / 2. Missing Error Handle
|
|
|
|
**Root Cause:**
|
|
```typescript
|
|
await axios.put(`${process.env.API_URL}/leave-beginning/schedule/update-dna`, payloads, {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
api_key: process.env.API_KEY,
|
|
},
|
|
timeout: 30000, // 30 second timeout
|
|
});
|
|
```
|
|
|
|
**ปัญหาที่พบ:**
|
|
1. **No error handling for external API** - หาก external API ล้มหรือ timeout จะเกิด unhandled exception
|
|
2. **No retry logic** - ไม่มีการ retry หาก external API ล้มชั่วคราว
|
|
3. **No validation of environment variables** - `process.env.API_URL` หรือ `process.env.API_KEY` อาจเป็น undefined
|
|
4. **Payload size not validated** - หาก payloads ขนาดใหญ่เกินไปอาจทำให้ external API ล้ม
|
|
5. **Circuit breaker not implemented** - หาก external API ล้มต่อเนื่อง จะไม่มีการหยุดชั่วคราว
|
|
|
|
**Recommended Fix:**
|
|
```typescript
|
|
// Add at class level
|
|
private apiFailureCount = 0;
|
|
private readonly API_FAILURE_THRESHOLD = 5;
|
|
private readonly API_RETRY_ATTEMPTS = 3;
|
|
private isCircuitOpen = false;
|
|
|
|
@Post("update-org")
|
|
public async cronjobUpdateOrg(@Request() request: RequestWithUser) {
|
|
// Idempotency check
|
|
if (this.isRunning) {
|
|
console.log("cronjobUpdateOrg: Job already running, skipping this execution");
|
|
return new HttpSuccess({
|
|
message: "Job already running",
|
|
skipped: true,
|
|
});
|
|
}
|
|
|
|
// Circuit breaker check
|
|
if (this.isCircuitOpen) {
|
|
console.warn("cronjobUpdateOrg: Circuit breaker is OPEN, skipping execution");
|
|
return new HttpSuccess({
|
|
message: "Circuit breaker is open",
|
|
skipped: true,
|
|
});
|
|
}
|
|
|
|
this.isRunning = true;
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// Validate environment variables first
|
|
const apiUrl = process.env.API_URL;
|
|
const apiKey = process.env.API_KEY;
|
|
|
|
if (!apiUrl) {
|
|
throw new Error("API_URL environment variable is not set");
|
|
}
|
|
if (!apiKey) {
|
|
throw new Error("API_KEY environment variable is not set");
|
|
}
|
|
|
|
const windowStart = new Date(Date.now() - this.UPDATE_WINDOW_HOURS * 60 * 60 * 1000);
|
|
|
|
console.log("cronjobUpdateOrg: Starting job", {
|
|
windowHours: this.UPDATE_WINDOW_HOURS,
|
|
windowStart: windowStart.toISOString(),
|
|
batchSize: this.BATCH_SIZE,
|
|
});
|
|
|
|
// ... existing database queries ...
|
|
|
|
// Build payloads
|
|
const payloads = this.buildPayloads(posMasters, posMasterEmployee, posMasterEmployeeTemp);
|
|
|
|
if (payloads.length === 0) {
|
|
console.log("cronjobUpdateOrg: No records to process");
|
|
return new HttpSuccess({
|
|
message: "No records to process",
|
|
processed: 0,
|
|
});
|
|
}
|
|
|
|
// Validate payload size before sending
|
|
if (payloads.length > 10000) {
|
|
console.warn(`cronjobUpdateOrg: Payload size ${payloads.length} exceeds 10000, splitting`);
|
|
}
|
|
|
|
// Update profile's org structure in leave service with retry logic
|
|
console.log("cronjobUpdateOrg: Calling leave service API", {
|
|
payloadCount: payloads.length,
|
|
});
|
|
|
|
let apiSuccess = false;
|
|
let lastError: any;
|
|
|
|
for (let attempt = 1; attempt <= this.API_RETRY_ATTEMPTS; attempt++) {
|
|
try {
|
|
await axios.put(
|
|
`${apiUrl}/leave-beginning/schedule/update-dna`,
|
|
payloads,
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
api_key: apiKey,
|
|
},
|
|
timeout: 60000, // 60 second timeout (increased)
|
|
validateStatus: (status) => status < 500, // Retry on server errors only
|
|
}
|
|
);
|
|
|
|
apiSuccess = true;
|
|
this.apiFailureCount = 0; // Reset failure count on success
|
|
console.log(`cronjobUpdateOrg: API call succeeded on attempt ${attempt}`);
|
|
break;
|
|
|
|
} catch (error: any) {
|
|
lastError = error;
|
|
console.error(`cronjobUpdateOrg: API call attempt ${attempt} failed:`, error.message);
|
|
|
|
// Don't retry on client errors (4xx)
|
|
if (error.response?.status >= 400 && error.response?.status < 500) {
|
|
break;
|
|
}
|
|
|
|
// Wait before retry with exponential backoff
|
|
if (attempt < this.API_RETRY_ATTEMPTS) {
|
|
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!apiSuccess) {
|
|
this.apiFailureCount++;
|
|
|
|
// Open circuit breaker if threshold reached
|
|
if (this.apiFailureCount >= this.API_FAILURE_THRESHOLD) {
|
|
this.isCircuitOpen = true;
|
|
console.error("cronjobUpdateOrg: Circuit breaker OPENED due to repeated failures");
|
|
|
|
// Auto-close after 5 minutes
|
|
setTimeout(() => {
|
|
this.isCircuitOpen = false;
|
|
this.apiFailureCount = 0;
|
|
console.log("cronjobUpdateOrg: Circuit breaker CLOSED");
|
|
}, 5 * 60 * 1000);
|
|
}
|
|
|
|
throw new HttpError(
|
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
`ไม่สามารถเรียก Leave Service API ได้: ${lastError?.message || 'Unknown error'}`
|
|
);
|
|
}
|
|
|
|
// ... rest of the method ...
|
|
|
|
} catch (error: any) {
|
|
const duration = Date.now() - startTime;
|
|
console.error("cronjobUpdateOrg: Job failed", {
|
|
duration: `${duration}ms`,
|
|
error: error.message,
|
|
stack: error.stack,
|
|
});
|
|
throw new HttpError(
|
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
`Internal server error: ${error?.message || 'Unknown error'}`
|
|
);
|
|
} finally {
|
|
this.isRunning = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3. 🔴 CRITICAL - ScriptProfileOrgController.ts - Race Condition in Idempotency Check
|
|
|
|
**File & Location:** [ScriptProfileOrgController.ts](src/controllers/ScriptProfileOrgController.ts:32-56) - Class properties and `cronjobUpdateOrg()` method
|
|
|
|
**Problem Type:** 1. Unhandled Exception / 2. Missing Error Handle
|
|
|
|
**Root Cause:**
|
|
```typescript
|
|
private isRunning = false;
|
|
|
|
@Post("update-org")
|
|
public async cronjobUpdateOrg(@Request() request: RequestWithUser) {
|
|
if (this.isRunning) {
|
|
console.log("cronjobUpdateOrg: Job already running, skipping this execution");
|
|
return new HttpSuccess({
|
|
message: "Job already running",
|
|
skipped: true,
|
|
});
|
|
}
|
|
|
|
this.isRunning = true;
|
|
// ... rest of the method
|
|
finally {
|
|
this.isRunning = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
**ปัญหาที่พบ:**
|
|
1. **Race condition** - หากมี 2 requests มาพร้อมกัน `isRunning` อาจถูกตั้งค่าเป็น false โดยทั้ง 2 requests อ่านเป็น false
|
|
2. **Stuck state** - หากเกิด error ก่อนถึง finally block หรือ process crash ทิ้ง `isRunning` จะค้างที่ true ตลอดไป
|
|
3. **No timeout** - หาก job ทำงานนานเกินไป ไม่มีกลไก timeout
|
|
4. **Instance variable in containerized environment** - ในระบบ microservices ที่มีหลาย instances แต่ละ instance จะมี `isRunning` เป็นของตัวเอง ทำให้อาจมีการรันซ้ำกัน
|
|
|
|
**Recommended Fix:**
|
|
```typescript
|
|
// Use Redis or database for distributed lock
|
|
import { createClient } from 'redis';
|
|
|
|
@Route("api/v1/org/script-profile-org")
|
|
@Tags("Keycloak Sync")
|
|
@Security("bearerAuth")
|
|
export class ScriptProfileOrgController extends Controller {
|
|
// ... existing repositories ...
|
|
|
|
private readonly redisClient = createClient({
|
|
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
|
socket: {
|
|
connectTimeout: 5000,
|
|
},
|
|
});
|
|
|
|
private readonly LOCK_TIMEOUT = 10 * 60 * 1000; // 10 minutes
|
|
private readonly LOCK_KEY = 'cronjob:update-org:lock';
|
|
|
|
private async acquireLock(): Promise<boolean> {
|
|
try {
|
|
await this.redisClient.connect();
|
|
const result = await this.redisClient.set(
|
|
this.LOCK_KEY,
|
|
'locked',
|
|
{
|
|
NX: true, // Only set if not exists
|
|
PX: this.LOCK_TIMEOUT, // Expire after timeout
|
|
}
|
|
);
|
|
return result === 'OK';
|
|
} catch (error) {
|
|
console.error('Failed to acquire lock:', error);
|
|
return false;
|
|
} finally {
|
|
await this.redisClient.quit();
|
|
}
|
|
}
|
|
|
|
private async releaseLock(): Promise<void> {
|
|
try {
|
|
await this.redisClient.connect();
|
|
await this.redisClient.del(this.LOCK_KEY);
|
|
} catch (error) {
|
|
console.error('Failed to release lock:', error);
|
|
} finally {
|
|
await this.redisClient.quit();
|
|
}
|
|
}
|
|
|
|
@Post("update-org")
|
|
public async cronjobUpdateOrg(@Request() request: RequestWithUser) {
|
|
// Try to acquire lock
|
|
const lockAcquired = await this.acquireLock();
|
|
|
|
if (!lockAcquired) {
|
|
console.log("cronjobUpdateOrg: Job already running or lock not acquired, skipping");
|
|
return new HttpSuccess({
|
|
message: "Job already running",
|
|
skipped: true,
|
|
});
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// ... rest of the method ...
|
|
|
|
const duration = Date.now() - startTime;
|
|
console.log("cronjobUpdateOrg: Job completed", {
|
|
duration: `${duration}ms`,
|
|
processed: payloads.length,
|
|
});
|
|
|
|
return new HttpSuccess({
|
|
message: "Update org completed",
|
|
processed: payloads.length,
|
|
syncResults,
|
|
duration: `${duration}ms`,
|
|
});
|
|
|
|
} catch (error: any) {
|
|
const duration = Date.now() - startTime;
|
|
console.error("cronjobUpdateOrg: Job failed", {
|
|
duration: `${duration}ms`,
|
|
error: error.message,
|
|
stack: error.stack,
|
|
});
|
|
|
|
// Still release lock even on error
|
|
throw new HttpError(
|
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
`Internal server error: ${error?.message || 'Unknown error'}`
|
|
);
|
|
} finally {
|
|
// Always release lock
|
|
await this.releaseLock();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4. 🟡 HIGH - WorkflowController.ts - Missing Error Handling in Workflow Creation
|
|
|
|
**File & Location:** [WorkflowController.ts](src/controllers/WorkflowController.ts:46-273) - `checkWorkflow()` method
|
|
|
|
**Problem Type:** 2. Missing Error Handle
|
|
|
|
**Root Cause:**
|
|
```typescript
|
|
@Post("add-workflow")
|
|
public async checkWorkflow(
|
|
@Request() req: RequestWithUser,
|
|
@Body() body: { ... }
|
|
) {
|
|
const [userProfileOfficer, userProfileEmployee, metaWorkflow] = await Promise.all([
|
|
this.profileRepo.findOne({...}),
|
|
this.profileEmployeeRepo.findOne({...}),
|
|
this.metaWorkflowRepo.findOne({...}),
|
|
]);
|
|
|
|
let profileType = "OFFICER";
|
|
let profile: any = userProfileOfficer;
|
|
|
|
if (!profile) {
|
|
profileType = "EMPLOYEE";
|
|
profile = userProfileEmployee;
|
|
if (!profile) throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบข้อมูลผู้ใช้งาน");
|
|
}
|
|
|
|
if (!metaWorkflow) throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบกระบวนการนี้ได้");
|
|
|
|
// ... สร้าง workflow ...
|
|
|
|
const notificationReceivers = stateOperatorUsersToCreate
|
|
.filter((user) => firstStateOperators.some((op) => op.operator === user.operator))
|
|
.map((user) => ({
|
|
receiverUserId: user.profileType === "OFFICER" ? user.profileId : user.profileEmployeeId,
|
|
notiLink: notiLink,
|
|
}));
|
|
|
|
// ส่ง notification แบบ fire-and-forget
|
|
new CallAPI()
|
|
.PostData(req, "/placement/noti/profiles", {...})
|
|
.catch((error) => {
|
|
console.error("Error calling API:", error);
|
|
});
|
|
|
|
return new HttpSuccess();
|
|
}
|
|
```
|
|
|
|
**ปัญหาที่พบ:**
|
|
1. **Partial error handling** - มี try-catch รอบ workflow creation แต่ไม่ครอบคลุมทั้งหมด
|
|
2. **Notification failure doesn't affect workflow** - หาก notification ล้ม จะไม่ทำให้ workflow ล้มด้วย ซึ่งอาจเป็นที่ต้องการ แต่ควรมีการ log ไว้ชัดเจน
|
|
3. **No cleanup on partial failure** - หากสร้าง workflow สำเร็จแต่สร้าง states ล้ม จะมีข้อมูล partial อยู่ใน database
|
|
4. **Missing transaction** - การสร้าง workflow มีหลายขั้นตอนแต่ไม่มี transaction
|
|
|
|
**Recommended Fix:**
|
|
```typescript
|
|
@Post("add-workflow")
|
|
public async checkWorkflow(
|
|
@Request() req: RequestWithUser,
|
|
@Body() body: { ... }
|
|
) {
|
|
const queryRunner = AppDataSource.createQueryRunner();
|
|
await queryRunner.connect();
|
|
await queryRunner.startTransaction();
|
|
|
|
try {
|
|
// ขั้นที่ 1: ค้นหา profile และ metaWorkflow
|
|
const [userProfileOfficer, userProfileEmployee, metaWorkflow] = await Promise.all([
|
|
queryRunner.manager.findOne(Profile, {
|
|
where: { keycloak: req.user.sub },
|
|
select: ["id", "keycloak"],
|
|
}),
|
|
queryRunner.manager.findOne(ProfileEmployee, {
|
|
where: { keycloak: req.user.sub },
|
|
select: ["id", "keycloak"],
|
|
}),
|
|
queryRunner.manager.findOne(MetaWorkflow, {
|
|
where: {
|
|
sysName: body.sysName,
|
|
posLevelName: body.posLevelName,
|
|
posTypeName: body.posTypeName,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
let profileType = "OFFICER";
|
|
let profile: any = userProfileOfficer;
|
|
|
|
if (!profile) {
|
|
profileType = "EMPLOYEE";
|
|
profile = userProfileEmployee;
|
|
if (!profile) {
|
|
throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบข้อมูลผู้ใช้งาน");
|
|
}
|
|
}
|
|
|
|
if (!metaWorkflow) {
|
|
throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบกระบวนการนี้ได้");
|
|
}
|
|
|
|
const meta = {
|
|
createdUserId: req.user.sub,
|
|
createdFullName: req.user.name,
|
|
lastUpdateUserId: req.user.sub,
|
|
lastUpdateFullName: req.user.name,
|
|
createdAt: new Date(),
|
|
lastUpdatedAt: new Date(),
|
|
};
|
|
|
|
// ขั้นที่ 2: สร้าง workflow และดึง metaState
|
|
const workflow = new Workflow();
|
|
Object.assign(workflow, {
|
|
...metaWorkflow,
|
|
id: undefined,
|
|
...meta,
|
|
...body,
|
|
profileType: profileType,
|
|
system: body.sysName,
|
|
});
|
|
|
|
const savedWorkflow = await queryRunner.manager.save(workflow);
|
|
|
|
const metaStates = await queryRunner.manager.find(MetaState, {
|
|
where: { metaWorkflowId: metaWorkflow.id },
|
|
order: { order: "ASC" },
|
|
});
|
|
|
|
// ขั้นที่ 3: สร้าง states
|
|
const statesToCreate = metaStates.map((item) => {
|
|
const state = new State();
|
|
Object.assign(state, { ...item, id: undefined, workflowId: savedWorkflow.id, ...meta });
|
|
return state;
|
|
});
|
|
|
|
const savedStates = await queryRunner.manager.save(statesToCreate);
|
|
|
|
// ขั้นที่ 4: อัปเดต workflow.stateId
|
|
const firstState = savedStates.find((state) => state.order === 1);
|
|
if (firstState) {
|
|
savedWorkflow.stateId = firstState.id;
|
|
await queryRunner.manager.save(savedWorkflow);
|
|
}
|
|
|
|
// ขั้นที่ 5: ดึงและสร้าง stateOperators
|
|
const metaStateIds = metaStates.map((item) => item.id);
|
|
const allMetaStateOperators = await queryRunner.manager.find(MetaStateOperator, {
|
|
where: { metaStateId: In(metaStateIds) },
|
|
});
|
|
|
|
const stateOperatorsToCreate: StateOperator[] = [];
|
|
|
|
allMetaStateOperators.forEach((metaStateOp) => {
|
|
const correspondingState = savedStates.find(
|
|
(state) =>
|
|
metaStates.find((metaState) => metaState.id === metaStateOp.metaStateId)?.order ===
|
|
state.order,
|
|
);
|
|
|
|
if (body.isDeputy) {
|
|
if (body.sysName == "SYS_TRANSFER_REQ") {
|
|
if (metaStateOp.operator == "PersonnelOfficer" && correspondingState?.order == 1) {
|
|
return;
|
|
} else if (
|
|
metaStateOp.operator == "Officer" &&
|
|
[1, 2].includes(correspondingState?.order as number)
|
|
) {
|
|
metaStateOp.operator = "PersonnelOfficer";
|
|
}
|
|
}
|
|
if (
|
|
metaStateOp.operator == "Officer" &&
|
|
["REGISTRY_PROFILE", "REGISTRY_PROFILE_EMP", "REGISTRY_IDP"].includes(body.sysName)
|
|
) {
|
|
metaStateOp.operator = "PersonnelOfficer";
|
|
}
|
|
}
|
|
|
|
if (correspondingState) {
|
|
const stateOperator = new StateOperator();
|
|
Object.assign(stateOperator, {
|
|
...metaStateOp,
|
|
id: undefined,
|
|
stateId: correspondingState.id,
|
|
...meta,
|
|
});
|
|
stateOperatorsToCreate.push(stateOperator);
|
|
}
|
|
});
|
|
|
|
await queryRunner.manager.save(stateOperatorsToCreate);
|
|
|
|
// ขั้นที่ 6: สร้าง StateOperatorUsers
|
|
const stateOperatorUsersToCreate: StateOperatorUser[] = [];
|
|
let orderNum = 1;
|
|
|
|
if (profile) {
|
|
const ownerStateOperatorUser = new StateOperatorUser();
|
|
Object.assign(ownerStateOperatorUser, {
|
|
profileId: profileType === "OFFICER" ? profile.id : null,
|
|
profileEmployeeId: profileType !== "OFFICER" ? profile.id : null,
|
|
profileType: profileType,
|
|
operator: "Owner",
|
|
order: orderNum,
|
|
workflowId: savedWorkflow.id,
|
|
...meta,
|
|
});
|
|
stateOperatorUsersToCreate.push(ownerStateOperatorUser);
|
|
}
|
|
|
|
const profileOfficers = await queryRunner.manager.find(PosMaster, {
|
|
where: {
|
|
posMasterAssigns: { assignId: body.sysName },
|
|
orgRevision: { orgRevisionIsDraft: false, orgRevisionIsCurrent: true },
|
|
current_holderId: Not(IsNull()),
|
|
...(body.orgRootId && { orgRootId: body.orgRootId }),
|
|
},
|
|
relations: ["orgChild1"],
|
|
});
|
|
|
|
profileOfficers.forEach((item) => {
|
|
if (item.current_holderId) {
|
|
orderNum += 1;
|
|
const isPersonnelOfficer = item.orgChild1?.isOfficer === true;
|
|
|
|
const officerStateOperatorUser = new StateOperatorUser();
|
|
Object.assign(officerStateOperatorUser, {
|
|
profileId: item.current_holderId,
|
|
operator: isPersonnelOfficer ? "PersonnelOfficer" : "Officer",
|
|
profileType: "OFFICER",
|
|
order: orderNum,
|
|
workflowId: savedWorkflow.id,
|
|
...meta,
|
|
});
|
|
stateOperatorUsersToCreate.push(officerStateOperatorUser);
|
|
}
|
|
});
|
|
|
|
await queryRunner.manager.save(stateOperatorUsersToCreate);
|
|
|
|
// Commit transaction before sending notification
|
|
await queryRunner.commitTransaction();
|
|
|
|
// ขั้นที่ 7: ส่ง notification (outside transaction)
|
|
const firstStateOperators = stateOperatorsToCreate.filter((so) =>
|
|
savedStates.find((state) => state.id === so.stateId && state.order === 1),
|
|
);
|
|
|
|
let notiLink = "";
|
|
if (body.sysName === "REGISTRY_PROFILE") {
|
|
notiLink = `${process.env.VITE_URL_MGT}/registry-officer/request-edit/personal/${body.refId}`;
|
|
} else if (body.sysName === "REGISTRY_PROFILE_EMP") {
|
|
notiLink = `${process.env.VITE_URL_MGT}/registry-employee/request-edit/personal/${body.refId}`;
|
|
} else if (body.sysName === "REGISTRY_IDP") {
|
|
notiLink = `${process.env.VITE_URL_MGT}/registry-officer/request-edit-page/${body.refId}`;
|
|
}
|
|
|
|
const notificationReceivers = stateOperatorUsersToCreate
|
|
.filter((user) => firstStateOperators.some((op) => op.operator === user.operator))
|
|
.map((user) => ({
|
|
receiverUserId: user.profileType === "OFFICER" ? user.profileId : user.profileEmployeeId,
|
|
notiLink: notiLink,
|
|
}));
|
|
|
|
// ส่ง notification แบบ fire-and-forget แต่ log ไว้ชัดเจน
|
|
new CallAPI()
|
|
.PostData(req, "/placement/noti/profiles", {
|
|
subject: `แจ้ง${savedWorkflow.name}ของ ${body.fullName}`,
|
|
body: `แจ้ง${savedWorkflow.name}ของ ${body.fullName}`,
|
|
receiverUserIds: notificationReceivers,
|
|
payload: "",
|
|
isSendMail: true,
|
|
isSendInbox: true,
|
|
isSendNotification: true,
|
|
})
|
|
.then(() => {
|
|
console.log(`[Workflow] Notification sent successfully for workflow ${savedWorkflow.id}`);
|
|
})
|
|
.catch((error) => {
|
|
console.error(`[Workflow] Failed to send notification for workflow ${savedWorkflow.id}:`, error);
|
|
// Log แต่ไม่ throw เพราะ workflow สร้างสำเร็จแล้ว
|
|
});
|
|
|
|
return new HttpSuccess();
|
|
|
|
} catch (error: any) {
|
|
await queryRunner.rollbackTransaction();
|
|
console.error('[Workflow] Failed to create workflow:', error);
|
|
throw new HttpError(
|
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
`ไม่สามารถสร้าง workflow ได้: ${error?.message || 'Unknown error'}`
|
|
);
|
|
} finally {
|
|
await queryRunner.release();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5. 🟡 MEDIUM - ProfileSalaryTempController.ts - SQL Injection Risk
|
|
|
|
**File & Location:** [ProfileSalaryTempController.ts](src/controllers/ProfileSalaryTempController.ts:173-225) - `listProfile()` method
|
|
|
|
**Problem Type:** 2. Missing Error Handle
|
|
|
|
**Root Cause:**
|
|
```typescript
|
|
.andWhere(
|
|
new Brackets((qb) => {
|
|
qb.orWhere(
|
|
searchKeyword != null && searchKeyword != ""
|
|
? `profile.citizenId like '%${searchKeyword}%'`
|
|
: "1=1",
|
|
)
|
|
// ... หลาย orWhere โดยใส่ค่าโดยตรง
|
|
}),
|
|
)
|
|
```
|
|
|
|
**ปัญหาที่พบ:**
|
|
1. **SQL Injection vulnerability** - การใส่ `searchKeyword` โดยตรงเข้าไปใน query string อาจทำให้เกิด SQL injection
|
|
2. **Query syntax error** - หาก `searchKeyword` มีตัวอักษรพิเศษเช่น single quote (`'`) จะทำให้ query error
|
|
3. **No input sanitization** - ไม่มีการ sanitize ข้อมูลก่อนใช้ใน query
|
|
|
|
**Recommended Fix:**
|
|
```typescript
|
|
.andWhere(
|
|
new Brackets((qb) => {
|
|
const keywordParam = `%${searchKeyword}%`;
|
|
|
|
if (searchKeyword != null && searchKeyword != "") {
|
|
qb.orWhere("profile.citizenId LIKE :keyword", { keyword: keywordParam })
|
|
.orWhere("profile.position LIKE :keyword", { keyword: keywordParam })
|
|
.orWhere(
|
|
"CONCAT(profile.prefix, profile.firstName, ' ', profile.lastName) LIKE :keyword",
|
|
{ keyword: keywordParam }
|
|
)
|
|
.orWhere("posType.posTypeName LIKE :keyword", { keyword: keywordParam })
|
|
.orWhere("posLevel.posLevelName LIKE :keyword", { keyword: keywordParam })
|
|
.orWhere("orgRoot.orgRootName LIKE :keyword", { keyword: keywordParam })
|
|
// ... ใช้ parameterized query ทุกครั้ง
|
|
} else {
|
|
qb.where("1=1");
|
|
}
|
|
}),
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
### 6. 🟡 MEDIUM - WorkflowController.ts - Invalid Switch Case
|
|
|
|
**File & Location:** [WorkflowController.ts](src/controllers/WorkflowController.ts:311-337) - `checkIsCan()` method
|
|
|
|
**Problem Type:** 2. Missing Error Handle
|
|
|
|
**Root Cause:**
|
|
```typescript
|
|
switch (body.action.trim().toLocaleUpperCase()) {
|
|
case "VIEW":
|
|
isCan = operator.canView;
|
|
case "UPDATE":
|
|
isCan = operator.canUpdate;
|
|
case "DELETE":
|
|
isCan = operator.canDelete;
|
|
case "CANCEL":
|
|
isCan = operator.canCancel;
|
|
case "OPERATE":
|
|
isCan = operator.canOperate;
|
|
case "CHANGESTATE":
|
|
isCan = operator.canChangeState;
|
|
case "COMMENT":
|
|
isCan = operator.canComment;
|
|
case "SIGN":
|
|
isCan = operator.canSign;
|
|
default:
|
|
isCan = false;
|
|
}
|
|
```
|
|
|
|
**ปัญหาที่พบ:**
|
|
1. **Missing break statements** - ไม่มี `break` หลังแต่ละ case ทำให้ fall-through ไป case ถัดไปเสมอ
|
|
2. **Logic error** - เช่นถ้า action เป็น "VIEW" จะเช็ค canView แล้ว fall-through ไปเช็ค canUpdate, canDelete, ... และสุดท้ายจะเป็นค่าจาก default case
|
|
|
|
**Recommended Fix:**
|
|
```typescript
|
|
switch (body.action.trim().toLocaleUpperCase()) {
|
|
case "VIEW":
|
|
isCan = operator.canView;
|
|
break;
|
|
case "UPDATE":
|
|
isCan = operator.canUpdate;
|
|
break;
|
|
case "DELETE":
|
|
isCan = operator.canDelete;
|
|
break;
|
|
case "CANCEL":
|
|
isCan = operator.canCancel;
|
|
break;
|
|
case "OPERATE":
|
|
isCan = operator.canOperate;
|
|
break;
|
|
case "CHANGESTATE":
|
|
isCan = operator.canChangeState;
|
|
break;
|
|
case "COMMENT":
|
|
isCan = operator.canComment;
|
|
break;
|
|
case "SIGN":
|
|
isCan = operator.canSign;
|
|
break;
|
|
default:
|
|
isCan = false;
|
|
break;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 7. 🟡 MEDIUM - ProfileTrainingController.ts - Unhandled Promise Rejection
|
|
|
|
**File & Location:** [ProfileTrainingController.ts](src/controllers/ProfileTrainingController.ts:158-163) - `editTraining()` method
|
|
|
|
**Problem Type:** 2. Missing Error Handle
|
|
|
|
**Root Cause:**
|
|
```typescript
|
|
await Promise.all([
|
|
this.trainingRepo.save(record, { data: req }),
|
|
setLogDataDiff(req, { before, after: record }),
|
|
this.trainingHistoryRepo.save(history, { data: req }),
|
|
]);
|
|
```
|
|
|
|
**ปัญหาที่พบ:**
|
|
1. **Unhandled Promise rejection** - หาก operation ใดๆ ใน Promise.all ล้ม จะเกิด unhandled rejection
|
|
2. **Partial data inconsistency** - หาก `save(record)` สำเร็จ แต่ `save(history)` ล้ม จะมีข้อมูลไม่สอดคล้องกัน
|
|
3. **No try-catch** - ไม่มีการ handle error เลย
|
|
|
|
**Recommended Fix:**
|
|
```typescript
|
|
@Patch("{trainingId}")
|
|
public async editTraining(
|
|
@Request() req: RequestWithUser,
|
|
@Body() body: UpdateProfileTraining,
|
|
@Path() trainingId: string,
|
|
) {
|
|
const queryRunner = AppDataSource.createQueryRunner();
|
|
await queryRunner.connect();
|
|
await queryRunner.startTransaction();
|
|
|
|
try {
|
|
const record = await queryRunner.manager.findOne(ProfileTraining, {
|
|
where: { id: trainingId }
|
|
});
|
|
|
|
if (!record) {
|
|
throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบข้อมูล");
|
|
}
|
|
|
|
await new permission().PermissionOrgUserUpdate(req, "SYS_REGISTRY_OFFICER", record.profileId);
|
|
|
|
const before = structuredClone(record);
|
|
const history = new ProfileTrainingHistory();
|
|
|
|
Object.assign(record, body);
|
|
Object.assign(history, { ...record, id: undefined });
|
|
|
|
history.profileTrainingId = trainingId;
|
|
record.lastUpdateUserId = req.user.sub;
|
|
record.lastUpdateFullName = req.user.name;
|
|
record.lastUpdatedAt = new Date();
|
|
history.lastUpdateUserId = req.user.sub;
|
|
history.lastUpdateFullName = req.user.name;
|
|
history.createdUserId = req.user.sub;
|
|
history.createdFullName = req.user.name;
|
|
history.createdAt = new Date();
|
|
history.lastUpdatedAt = new Date();
|
|
|
|
// Save within transaction
|
|
await queryRunner.manager.save(record);
|
|
|
|
// Log outside transaction but handle error gracefully
|
|
try {
|
|
setLogDataDiff(req, { before, after: record });
|
|
} catch (logError) {
|
|
console.error('[ProfileTraining] Failed to log data diff:', logError);
|
|
// Continue anyway - log failure shouldn't break the operation
|
|
}
|
|
|
|
await queryRunner.manager.save(history);
|
|
await queryRunner.commitTransaction();
|
|
|
|
return new HttpSuccess();
|
|
|
|
} catch (error: any) {
|
|
await queryRunner.rollbackTransaction();
|
|
|
|
if (error instanceof HttpError) {
|
|
throw error;
|
|
}
|
|
|
|
throw new HttpError(
|
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
`ไม่สามารถแก้ไขข้อมูลได้: ${error?.message || 'Unknown error'}`
|
|
);
|
|
} finally {
|
|
await queryRunner.release();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 8. 🟢 LOW - ProfileTrainingController.ts - Missing Validation
|
|
|
|
**File & Location:** [ProfileTrainingController.ts](src/controllers/ProfileTrainingController.ts:233-260) - `deleteAllTraining()` method
|
|
|
|
**Problem Type:** 2. Missing Error Handle
|
|
|
|
**Root Cause:**
|
|
```typescript
|
|
@Post("delete-all")
|
|
public async deleteAllTraining(
|
|
@Body() reqBody: { developmentId: string },
|
|
@Request() req: RequestWithUser
|
|
) {
|
|
const trainings = await this.trainingRepo.find({
|
|
select: { id: true },
|
|
where: { developmentId: reqBody.developmentId },
|
|
});
|
|
if (trainings.length > 0) {
|
|
const trainingIds = trainings.map((x) => x.id);
|
|
await this.trainingHistoryRepo.delete({
|
|
profileTrainingId: In(trainingIds),
|
|
});
|
|
await this.trainingRepo.delete({
|
|
developmentId: reqBody.developmentId,
|
|
});
|
|
}
|
|
|
|
await this.developmentHistoryRepo.delete({
|
|
kpiDevelopmentId: reqBody.developmentId,
|
|
});
|
|
await this.developmentRepo.delete({
|
|
kpiDevelopmentId: reqBody.developmentId
|
|
});
|
|
|
|
return new HttpSuccess();
|
|
}
|
|
```
|
|
|
|
**ปัญหาที่พบ:**
|
|
1. **No input validation** - ไม่ validate `developmentId` ว่ามีค่าหรือไม่
|
|
2. **No try-catch** - ไม่มี error handling เลย
|
|
3. **No transaction** - การลบข้อมูลหลายตารางไม่อยู่ใน transaction อาจเกิด partial delete
|
|
|
|
**Recommended Fix:**
|
|
```typescript
|
|
@Post("delete-all")
|
|
public async deleteAllTraining(
|
|
@Body() reqBody: { developmentId: string },
|
|
@Request() req: RequestWithUser
|
|
) {
|
|
// Validate input
|
|
if (!reqBody.developmentId || reqBody.developmentId.trim() === "") {
|
|
throw new HttpError(HttpStatus.BAD_REQUEST, "developmentId ต้องไม่ว่างเปล่า");
|
|
}
|
|
|
|
const queryRunner = AppDataSource.createQueryRunner();
|
|
await queryRunner.connect();
|
|
await queryRunner.startTransaction();
|
|
|
|
try {
|
|
const trainings = await queryRunner.manager.find(ProfileTraining, {
|
|
select: { id: true },
|
|
where: { developmentId: reqBody.developmentId },
|
|
});
|
|
|
|
if (trainings.length > 0) {
|
|
const trainingIds = trainings.map((x) => x.id);
|
|
|
|
await queryRunner.manager.delete(ProfileTrainingHistory, {
|
|
profileTrainingId: In(trainingIds),
|
|
});
|
|
|
|
await queryRunner.manager.delete(ProfileTraining, {
|
|
developmentId: reqBody.developmentId,
|
|
});
|
|
}
|
|
|
|
await queryRunner.manager.delete(ProfileDevelopmentHistory, {
|
|
kpiDevelopmentId: reqBody.developmentId,
|
|
});
|
|
|
|
await queryRunner.manager.delete(ProfileDevelopment, {
|
|
kpiDevelopmentId: reqBody.developmentId
|
|
});
|
|
|
|
await queryRunner.commitTransaction();
|
|
|
|
return new HttpSuccess({
|
|
message: "ลบข้อมูลเสร็จสิ้น",
|
|
deletedTrainings: trainings.length
|
|
});
|
|
|
|
} catch (error: any) {
|
|
await queryRunner.rollbackTransaction();
|
|
|
|
throw new HttpError(
|
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
`ไม่สามารถลบข้อมูลได้: ${error?.message || 'Unknown error'}`
|
|
);
|
|
} finally {
|
|
await queryRunner.release();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## สรุปสถิติ
|
|
|
|
| ระดับความรุนแรง | จำนวน | รายการ |
|
|
|---|---|---|
|
|
| 🔴 CRITICAL | 3 | 1, 2, 3 |
|
|
| 🟡 HIGH | 1 | 4 |
|
|
| 🟡 MEDIUM | 2 | 5, 6 |
|
|
| 🟢 LOW | 1 | 7 |
|
|
|
|
---
|
|
|
|
## คำแนะนำการจัดลำดับการแก้ไข
|
|
|
|
### แก้ไขทันที (P0 - Critical)
|
|
1. **ProfileSalaryTempController.ts** - `changeSortEditGenAll()` method
|
|
- เพิ่ม pagination
|
|
- เพิ่ม transaction management
|
|
- เพิ่ม error handling per iteration
|
|
|
|
2. **ScriptProfileOrgController.ts** - External API calls
|
|
- เพิ่ม retry logic
|
|
- เพิ่ม circuit breaker
|
|
- แก้ race condition ใน idempotency check
|
|
|
|
3. **ScriptProfileOrgController.ts** - Distributed locking
|
|
- ใช้ Redis หรือ database lock แทน instance variable
|
|
- เพิ่ม auto-release mechanism
|
|
|
|
### แก้ไขเร็วๆ นี้ (P1 - High)
|
|
4. **WorkflowController.ts** - Transaction management
|
|
- เพิ่ม transaction สำหรับ workflow creation
|
|
- เพิ่ม cleanup บน partial failure
|
|
|
|
### แก้ไขในภายหลัง (P2 - Medium)
|
|
5. **ProfileSalaryTempController.ts** - SQL Injection prevention
|
|
- ใช้ parameterized query
|
|
|
|
6. **WorkflowController.ts** - Fix switch case
|
|
- เพิ่ม break statements
|
|
|
|
### แก้ไขเมื่อว่าง (P3 - Low)
|
|
7. **ProfileTrainingController.ts** - Input validation
|
|
- เพิ่ม validation และ error handling
|
|
|
|
---
|
|
|
|
## ข้อเสนอแนะเพิ่มเติม
|
|
|
|
1. **Redis/Distributed Lock** - สำหรับ cronjobs ใน containerized environment
|
|
2. **Circuit Breaker Pattern** - สำหรับ external API calls
|
|
3. **Retry with Exponential Backoff** - สำหรับ operations ที่อาจล้มชั่วคราว
|
|
4. **Structured Logging** - เพิ่ม logging ที่มีโครงสร้างเพื่อ debugging และ monitoring
|
|
5. **Health Check Endpoints** - สำหรับตรวจสอบสถานะของ service และ dependencies
|
|
6. **Graceful Shutdown** - ให้แน่ใจว่า long-running operations สามารถ handle shutdown ได้
|
|
|
|
---
|
|
|
|
## ไฟล์รายงานที่เกี่ยวข้อง
|
|
|
|
- [Batch 1-10 Analysis](../reports/) - รายงานการตรวจสอบ Controllers ชุดก่อนหน้า
|
|
- [Security Audit Report](../reports/security-audit.md) - รายงานการตรวจสอบด้านความปลอดภัย
|