hrms-api-org/reports/batch-11-controllers-101-110-analysis.md

1161 lines
39 KiB
Markdown
Raw Normal View History

2026-05-08 18:15:03 +07:00
# รายงานการตรวจสอบ 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) - รายงานการตรวจสอบด้านความปลอดภัย