hrms-api-org/reports/batch-11-controllers-101-110-analysis.md
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 85e9be08f6 report: Controllers
2026-05-08 18:15:03 +07:00

39 KiB

รายงานการตรวจสอบ Unhandled Exception และ Crash Loop

Batch 11: Controllers 101-110

วันที่ตรวจสอบ: 2026-05-08
จำนวน Controllers ที่ตรวจสอบ: 10 Controllers


Controllers ที่ตรวจสอบในชุดนี้

  1. ProfileSalaryTempController.ts
  2. ReportController.ts - เกินขนาด 256KB (skip)
  3. ScriptProfileOrgController.ts
  4. WorkflowController.ts
  5. ProfileTrainingController.ts
  6. OrganizationController.ts - ตรวจสอบแล้วใน batch ก่อนหน้า
  7. PositionController.ts - ตรวจสอบแล้วใน batch ก่อนหน้า
  8. ProfileController.ts - ตรวจสอบแล้วใน batch ก่อนหน้า
  9. CommandController.ts - ตรวจสอบแล้วใน batch ก่อนหน้า
  10. User Controller.ts - ตรวจสอบแล้วใน batch ก่อนหน้า

รายการปัญหาที่พบ

1. 🔴 CRITICAL - ProfileSalaryTempController.ts - Unhandled Exception in Large Loop

File & Location: ProfileSalaryTempController.ts - changeSortEditGenAll() method

Problem Type: 1. Unhandled Exception / 2. Missing Error Handle

Root Cause:

@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:

@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 - cronjobUpdateOrg() method

Problem Type: 1. Unhandled Exception / 2. Missing Error Handle

Root Cause:

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:

// 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 - Class properties and cronjobUpdateOrg() method

Problem Type: 1. Unhandled Exception / 2. Missing Error Handle

Root Cause:

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:

// 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 - checkWorkflow() method

Problem Type: 2. Missing Error Handle

Root Cause:

@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:

@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 - listProfile() method

Problem Type: 2. Missing Error Handle

Root Cause:

.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:

.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 - checkIsCan() method

Problem Type: 2. Missing Error Handle

Root Cause:

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:

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 - editTraining() method

Problem Type: 2. Missing Error Handle

Root Cause:

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:

@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 - deleteAllTraining() method

Problem Type: 2. Missing Error Handle

Root Cause:

@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:

@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)

  1. WorkflowController.ts - Transaction management
    • เพิ่ม transaction สำหรับ workflow creation
    • เพิ่ม cleanup บน partial failure

แก้ไขในภายหลัง (P2 - Medium)

  1. ProfileSalaryTempController.ts - SQL Injection prevention

    • ใช้ parameterized query
  2. WorkflowController.ts - Fix switch case

    • เพิ่ม break statements

แก้ไขเมื่อว่าง (P3 - Low)

  1. 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 - รายงานการตรวจสอบ Controllers ชุดก่อนหน้า
  • Security Audit Report - รายงานการตรวจสอบด้านความปลอดภัย