# รายงานการตรวจสอบ 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 { 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 { 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) - รายงานการตรวจสอบด้านความปลอดภัย