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

27 KiB

รายงานการตรวจสอบ Unhandled Exception - Controllers ชุดที่ 2 (ไฟล์ที่ 11-20)

Project: BMA EHR Organization Backend Framework: TSOA + Express + TypeORM วันที่ตรวจสอบ: 2026-05-08 จำนวน Controllers: 10 ไฟล์ สถานะ: เสร็จสิ้น


สรุปผลการตรวจสอบ

ระดับความรุนแรง จำนวนจุดเสี่ยง
CRITICAL 1
HIGH 3
MEDIUM 4
LOW 2
BUG 2
รวมทั้งหมด 12

Controllers ที่ตรวจสอบ

  1. CommandOperatorController.ts
  2. CommandSalaryController.ts
  3. CommandSysController.ts
  4. CommandTypeController.ts
  5. DPISController.ts
  6. DevelopmentRequestController.ts
  7. DistrictController.ts
  8. EducationLevelController.ts
  9. EmployeePosLevelController.ts
  10. EmployeePosTypeController.ts

รายละเอียดจุดเสี่ยงแต่ละจุด

#1 - Transaction QueryRunner Not Released on Error (CRITICAL)

File & Location: CommandOperatorController.ts:169-222 Method: deleteCommandOperator

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

Root Cause:

  • ใช้ QueryRunner และ Transaction แต่มี error handling ที่ไม่ปลอดภัย
  • ถ้าเกิด error หลังจาก throw error ใน catch block แล้ว จะไม่ถึง finally block
  • QueryRunner จะไม่ถูก release ทำให้ connection leak
  • ในกรณีที่ HttpError ถูก throw ภายใน catch จะไม่มีการ release queryRunner

Code ปัจจุบัน (เสี่ยง):

const queryRunner = AppDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

try {
  // ... operations
  await queryRunner.commitTransaction();
  return new HttpSuccess(true);
} catch (error) {
  await queryRunner.rollbackTransaction();
  throw error; // ❌ ถ้า throw HttpError จะไม่ถึง finally
} finally {
  await queryRunner.release(); // ❌ จะไม่ถูกเรียกถ้า throw error ใน catch
}

Recommended Fix:

const queryRunner = AppDataSource.createQueryRunner();
try {
  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
    // 1. หา operator
    const operator = await queryRunner.manager.findOne(CommandOperator, {
      where: {
        id: operatorId,
        commandId: commandId,
      },
    });

    if (!operator) {
      throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบเจ้าหน้าที่ดำเนินการ");
    }

    const removedOrderNo = operator.orderNo;

    // 3. ลบ
    await queryRunner.manager.remove(operator);

    // 4. re orderNumber ตัวที่เหลือ
    await queryRunner.manager
      .createQueryBuilder()
      .update(CommandOperator)
      .set({
        orderNo: () => "orderNo - 1",
      })
      .where("commandId = :commandId", { commandId })
      .andWhere("orderNo > :removedOrderNo", { removedOrderNo })
      .execute();

    await queryRunner.commitTransaction();
    return new HttpSuccess(true);
  } catch (error) {
    await queryRunner.rollbackTransaction();
    // Re-throw after rollback
    throw error;
  }
} finally {
  // ✅ ใช้ finally block ระดับนอกสุดเพื่อให้แน่ใจว่าจะถูกเรียกเสมอ
  if (queryRunner.isReleased) {
    // Already released, skip
  } else {
    await queryRunner.release();
  }
}

#2 - Promise.all Without Error Handling in Development Request (HIGH)

File & Location: DevelopmentRequestController.ts:349-364 Method: newDevelopmentRequest

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

Root Cause:

  • Promise.all() กับการบันทึก development projects หลายรายการ
  • ถ้ามี project ไหน save ไม่สำเร็จ จะทำให้ unhandled rejection
  • ไม่มี try-catch รองรับ
  • External API call ใช้ .catch() แต่ไม่ได้ throw error ต่อ

Code ปัจจุบัน (เสี่ยง):

if (body.developmentProjects != null) {
  await Promise.all(
    body.developmentProjects.map(async (x) => {
      let developmentProject = new DevelopmentProject();
      developmentProject.name = x;
      developmentProject.createdUserId = req.user.sub;
      developmentProject.createdFullName = req.user.name;
      developmentProject.lastUpdateUserId = req.user.sub;
      developmentProject.lastUpdateFullName = req.user.name;
      developmentProject.createdAt = new Date();
      developmentProject.lastUpdatedAt = new Date();
      developmentProject.developmentRequestId = data.id;
      await this.developmentProjectRepository.save(developmentProject, { data: req });
      setLogDataDiff(req, { before, after: developmentProject });
    }),
  );
}
await new CallAPI()
  .PostData(req, "/org/workflow/add-workflow", {
    refId: data.id,
    sysName: "REGISTRY_IDP",
    posLevelName: profile.posLevel.posLevelName,
    posTypeName: profile.posType.posTypeName,
    fullName: `${profile.prefix}${profile.firstName} ${profile.lastName}`,
    isDeputy: orgRoot?.isDeputy ?? false,
    orgRootId: orgRoot?.id ?? null
  })
  .catch((error) => {
    console.error("Error calling API:", error);
  });

Recommended Fix:

if (body.developmentProjects != null) {
  try {
    await Promise.all(
      body.developmentProjects.map(async (x) => {
        try {
          let developmentProject = new DevelopmentProject();
          developmentProject.name = x;
          developmentProject.createdUserId = req.user.sub;
          developmentProject.createdFullName = req.user.name;
          developmentProject.lastUpdateUserId = req.user.sub;
          developmentProject.lastUpdateFullName = req.user.name;
          developmentProject.createdAt = new Date();
          developmentProject.lastUpdatedAt = new Date();
          developmentProject.developmentRequestId = data.id;
          await this.developmentProjectRepository.save(developmentProject, { data: req });
          setLogDataDiff(req, { before, after: developmentProject });
        } catch (error) {
          console.error(`Failed to save development project "${x}":`, error);
          throw error; // Re-throw to be caught by Promise.all
        }
      }),
    );
  } catch (error) {
    console.error("Failed to save some development projects:", error);
    throw new HttpError(
      HttpStatus.INTERNAL_SERVER_ERROR,
      "Failed to save development projects"
    );
  }
}

// Call external API with proper error handling
try {
  await new CallAPI().PostData(req, "/org/workflow/add-workflow", {
    refId: data.id,
    sysName: "REGISTRY_IDP",
    posLevelName: profile.posLevel.posLevelName,
    posTypeName: profile.posType.posTypeName,
    fullName: `${profile.prefix}${profile.firstName} ${profile.lastName}`,
    isDeputy: orgRoot?.isDeputy ?? false,
    orgRootId: orgRoot?.id ?? null
  });
} catch (error) {
  console.error("Failed to call workflow API:", error);
  // Optionally mark the request as having workflow issues
  // But don't fail the entire request
}

#3 - QueryBuilder with Dynamic Conditions Without Error Handling (HIGH)

File & Location: DevelopmentRequestController.ts:122-265 Method: getDevelopmentRequestAdmin

Problem Type: 2. Missing Error Handle

Root Cause:

  • Complex QueryBuilder พร้อม dynamic conditions หลายอย่าง
  • ไม่มี try-catch รองรับ
  • Permission check อาจ throw error
  • Null reference risks หลายจุด (orgRevisionPublish?.id, data.root, etc.)

Recommended Fix:

@Get("admin")
public async getDevelopmentRequestAdmin(
  @Request() request: RequestWithUser,
  @Query("status") status: string,
  @Query("keyword") keyword: string = "",
  @Query("page") page: number = 1,
  @Query("pageSize") pageSize: number = 10,
  @Query("sortBy") sortBy?: string,
  @Query("descending") descending?: boolean,
) {
  try {
    // Validate inputs
    if (page < 1) throw new HttpError(HttpStatus.BAD_REQUEST, "Invalid page number");
    if (pageSize < 1 || pageSize > 1000) {
      throw new HttpError(HttpStatus.BAD_REQUEST, "Invalid page size");
    }

    let data = await new permission().PermissionOrgList(request, "SYS_REGISTRY_OFFICER");

    const orgRevisionPublish = await this.orgRevisionRepository
      .createQueryBuilder("orgRevision")
      .where("orgRevision.orgRevisionIsDraft = false")
      .andWhere("orgRevision.orgRevisionIsCurrent = true")
      .getOne();

    let query = await AppDataSource.getRepository(DevelopmentRequest)
      .createQueryBuilder("developmentRequest")
      .leftJoinAndSelect("developmentRequest.profile", "profile")
      .leftJoinAndSelect("profile.current_holders", "current_holders")
      .leftJoinAndSelect("current_holders.orgRevision", "orgRevision")
      .andWhere(
        status == undefined || status.trim().toUpperCase() == "ALL" || status == ""
          ? "1=1"
          : "developmentRequest.status = :status",
        {
          status: status == undefined || status == null ? "" : status.trim().toUpperCase(),
        },
      )
      .andWhere(
        orgRevisionPublish ? `current_holders.orgRevisionId = :revisionId` : "1=1",
        {
          revisionId: orgRevisionPublish?.id,
        },
      )
      // ... rest of the query

    const [lists, total] = await query
      .skip((page - 1) * pageSize)
      .take(pageSize)
      .getManyAndCount();

    const _data = lists.map((item) => ({ ...item, profile: null }));
    return new HttpSuccess({ data: _data, total });
  } catch (error) {
    if (error instanceof HttpError) {
      throw error;
    }
    console.error('Failed to get development requests:', error);
    throw new HttpError(
      HttpStatus.INTERNAL_SERVER_ERROR,
      "Failed to retrieve development requests"
    );
  }
}

#4 - Promise.all in Edit Development Request Without Error Handling (HIGH)

File & Location: DevelopmentRequestController.ts:402-417 Method: editUserDevelopmentRequest

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

Root Cause:

  • ใช้ Promise.all() โดยไม่มี error handling
  • Similar to #2 but in edit operation

Code ปัจจุบัน (เสี่ยง):

await this.developmentProjectRepository.delete({ developmentRequestId: record.id });
if (body.developmentProjects != null) {
  await Promise.all(
    body.developmentProjects.map(async (x) => {
      let developmentProject = new DevelopmentProject();
      developmentProject.name = x;
      developmentProject.createdUserId = req.user.sub;
      developmentProject.createdFullName = req.user.name;
      developmentProject.lastUpdateUserId = req.user.sub;
      developmentProject.lastUpdateFullName = req.user.name;
      developmentProject.createdAt = new Date();
      developmentProject.lastUpdatedAt = new Date();
      developmentProject.developmentRequestId = record.id;
      await this.developmentProjectRepository.save(developmentProject, { data: req });
      setLogDataDiff(req, { before: null, after: record });
    }),
  );
}

Recommended Fix:

await this.developmentProjectRepository.delete({ developmentRequestId: record.id });
if (body.developmentProjects != null) {
  try {
    await Promise.all(
      body.developmentProjects.map(async (x) => {
        try {
          let developmentProject = new DevelopmentProject();
          developmentProject.name = x;
          developmentProject.createdUserId = req.user.sub;
          developmentProject.createdFullName = req.user.name;
          developmentProject.lastUpdateUserId = req.user.sub;
          developmentProject.lastUpdateFullName = req.user.name;
          developmentProject.createdAt = new Date();
          developmentProject.lastUpdatedAt = new Date();
          developmentProject.developmentRequestId = record.id;
          await this.developmentProjectRepository.save(developmentProject, { data: req });
          setLogDataDiff(req, { before: null, after: developmentProject });
        } catch (error) {
          console.error(`Failed to update development project "${x}":`, error);
          throw error;
        }
      }),
    );
  } catch (error) {
    console.error("Failed to update development projects:", error);
    throw new HttpError(
      HttpStatus.INTERNAL_SERVER_ERROR,
      "Failed to update development projects"
    );
  }
}

#5 - Null Reference Risk in Profile Query (MEDIUM)

File & Location: DPISController.ts:272-275 Method: GetProfileCitizenIdAsync

Problem Type: 2. Missing Error Handle

Root Cause:

  • findRevision อาจเป็น null ถ้าไม่พบ current revision
  • การใช้ findRevision?.id ใน find() operation จะเป็น undefined
  • current_holders?.find() อาจ return undefined

Code ปัจจุบัน (เสี่ยง):

const findRevision = await this.orgRevisionRepo.findOne({
  where: { orgRevisionIsCurrent: true },
});
var current_holder = profile.current_holders?.find((x) => x.orgRevisionId == findRevision?.id);

const mapProfile: DPISResult = {
  // ...
  organization: {
    orgRootName: current_holder?.orgRoot?.orgRootName || "", // ❌ multiple levels of null checks
    orgChild1Name: current_holder?.orgChild1?.orgChild1Name || "",
    // ...
  },
};

Recommended Fix:

const findRevision = await this.orgRevisionRepo.findOne({
  where: { orgRevisionIsCurrent: true },
});

if (!findRevision) {
  throw new HttpError(
    HttpStatus.NOT_FOUND,
    "No current organization revision found"
  );
}

var current_holder = profile.current_holders?.find((x) => x.orgRevisionId == findRevision.id);

if (!current_holder) {
  throw new HttpError(
    HttpStatus.NOT_FOUND,
    "No current organization assignment found for this profile"
  );
}

const mapProfile: DPISResult = {
  // ...
  organization: {
    orgRootName: current_holder.orgRoot?.orgRootName || "",
    orgChild1Name: current_holder.orgChild1?.orgChild1Name || "",
    orgChild2Name: current_holder.orgChild2?.orgChild2Name || "",
    orgChild3Name: current_holder.orgChild3?.orgChild3Name || "",
    orgChild4Name: current_holder.orgChild4?.orgChild4Name || "",
  },
};

#6 - Unsafe Optional Chain in OrgRoot Query (MEDIUM)

File & Location: DevelopmentRequestController.ts:322-330 Method: newDevelopmentRequest

Problem Type: 2. Missing Error Handle

Root Cause:

  • ใช้ optional chaining (?.) และ nullish coalescing ใน query
  • find() อาจ return undefined และการใช้ ! (non-null assertion) อาจทำให้ runtime error

Code ปัจจุบัน (เสี่ยง):

const orgRoot = await this.orgRootRepo.findOne({
  select: {
    id: true,
    isDeputy: true
  },
  where: {
    id: profile.current_holders.find(x => x.orgRootId)!.orgRootId ?? "" // ❌ unsafe
  }
})

Recommended Fix:

const currentHolder = profile.current_holders.find(x => x.orgRootId);
if (!currentHolder || !currentHolder.orgRootId) {
  throw new HttpError(
    HttpStatus.BAD_REQUEST,
    "Profile must have a current organization assignment"
  );
}

const orgRoot = await this.orgRootRepo.findOne({
  select: {
    id: true,
    isDeputy: true
  },
  where: {
    id: currentHolder.orgRootId
  }
});

if (!orgRoot) {
  throw new HttpError(
    HttpStatus.NOT_FOUND,
    "Organization root not found"
  );
}

#7 - Promise.all in Admin Edit Without Error Handling (MEDIUM)

File & Location: DevelopmentRequestController.ts:467-490 Method: editAdminDevelopmentRequest

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

Root Cause:

  • Promise.all() กับ nested save operations
  • ไม่มี error handling สำหรับ individual promises

Recommended Fix:

if (record.developmentProjects != null) {
  try {
    await Promise.all(
      record.developmentProjects.map(async (x) => {
        try {
          let developmentProject = new DevelopmentProject();
          let developmentProjectHistory = new DevelopmentProject();
          Object.assign(developmentProject, {
            ...meta,
            id: undefined,
            name: record.name,
            profileDevelopmentId: profileDevelopment.id,
          });
          Object.assign(developmentProject, {
            ...meta,
            id: undefined,
            name: record.name,
            profileDevelopmentHistoryId: history.id,
          });
          await Promise.all([
            this.developmentProjectRepository.save(developmentProject, { data: req }),
            setLogDataDiff(req, { before: null, after: developmentProject }),
            this.developmentProjectRepository.save(developmentProjectHistory, { data: req }),
          ]);
        } catch (error) {
          console.error(`Failed to save development project for "${record.name}":`, error);
          throw error;
        }
      }),
    );
  } catch (error) {
    console.error("Failed to save development projects:", error);
    throw new HttpError(
      HttpStatus.INTERNAL_SERVER_ERROR,
      "Failed to save development projects"
    );
  }
}

#8 - QueryBuilder Parameters Without Validation (MEDIUM)

File & Location: CommandSalaryController.ts:73-108 Method: GetAdmin

Problem Type: 2. Missing Error Handle

Root Cause:

  • QueryBuilder พร้อม dynamic conditions
  • ไม่มี input validation
  • Page number validation เป็น optional (มี default value แต่ไม่ validate range)

Recommended Fix:

@Get("admin")
async GetAdmin(
  @Query("page") page: number = 1,
  @Query("pageSize") pageSize: number = 10,
  @Query() commandSysId?: string | null,
  @Query() isActive?: boolean | null,
  @Query() searchKeyword?: string | null,
) {
  try {
    // Validate inputs
    if (page < 1 || page > 10000) {
      throw new HttpError(HttpStatus.BAD_REQUEST, "Invalid page number");
    }
    if (pageSize < 1 || pageSize > 1000) {
      throw new HttpError(HttpStatus.BAD_REQUEST, "Invalid page size");
    }

    const [commandSalarys, total] = await this.commandSalaryRepository
      .createQueryBuilder("commandSalary")
      .andWhere(
        isActive != null && isActive != undefined ? "commandSalary.isActive = :isActive" : "1=1",
        {
          isActive:
            isActive == null || isActive == undefined ? null : `${isActive == true ? 1 : 0}`,
        },
      )
      // ... rest of query
      .getManyAndCount();
    return new HttpSuccess({ commandSalarys, total });
  } catch (error) {
    if (error instanceof HttpError) {
      throw error;
    }
    console.error('Failed to get command salaries:', error);
    throw new HttpError(
      HttpStatus.INTERNAL_SERVER_ERROR,
      "Failed to retrieve command salaries"
    );
  }
}

#9 - Hardcoded Response Data (LOW)

File & Location: CommandTypeController.ts:140-199 Method: GetById

Problem Type: 3. Code Quality

Root Cause:

  • Hardcoded template data ใน code
  • ไม่ flexible และยากต่อการ maintain
  • ควรเก็บใน database หรือ configuration

Code ปัจจุบัน (เสี่ยง):

if (_commandType.code == "C-PM-10") {
  let _commandType10: any;
  _commandType10 = {
    // ... hardcoded fields
    name1: "๑. ..........................ประธาน",
    name2: "๒. ..........................กรรมการ",
    // ...
  };
  _commandType = _commandType10;
} else if (["C-PM-21", "C-PM-23"].includes(_commandType.code)) {
  let _commandType21and23: any;
  _commandType21and23 = {
    // ... hardcoded fields
    persons: [
      {
        no: "",
        org: "",
        // ...
      },
    ],
  };
  _commandType = _commandType21and23;
}

Recommended Fix:

// Move these templates to database or configuration
const commandTemplates = await this.commandTemplateRepository.find({
  where: { commandTypeCode: _commandType.code }
});

if (commandTemplates.length > 0) {
  const template = commandTemplates[0];
  return new HttpSuccess({
    ..._commandType,
    ...template.templateData
  });
}

return new HttpSuccess(_commandType);

File & Location: CommandOperatorController.ts:109-112 Method: swapCommandOperator

Problem Type: 2. Missing Error Handle

Root Cause:

  • มีการ swap orderNo ระหว่าง 2 records
  • ไม่ได้ใช้ transaction
  • ถ้า save ตัวแรกสำเร็จ แต่ตัวที่สอง fail จะเกิด data inconsistency

Code ปัจจุบัน (เสี่ยง):

// swap
const temp = source.orderNo;
source.orderNo = dest.orderNo;
dest.orderNo = temp;

await Promise.all([
  this.commandOperatorRepo.save(source),
  this.commandOperatorRepo.save(dest),
]);

Recommended Fix:

const queryRunner = AppDataSource.createQueryRunner();
try {
  await queryRunner.connect();
  await queryRunner.startTransaction();

  // swap
  const temp = source.orderNo;
  source.orderNo = dest.orderNo;
  dest.orderNo = temp;

  await Promise.all([
    queryRunner.manager.save(CommandOperator, source),
    queryRunner.manager.save(CommandOperator, dest),
  ]);

  await queryRunner.commitTransaction();
  return new HttpSuccess();
} catch (error) {
  await queryRunner.rollbackTransaction();
  console.error('Failed to swap command operators:', error);
  throw new HttpError(
    HttpStatus.INTERNAL_SERVER_ERROR,
    "Failed to swap operator order"
  );
} finally {
  await queryRunner.release();
}

#11 - Wrong Error Status Code (BUG)

File & Location: Multiple Files - CommandSysController.ts:127, CommandTypeController.ts:216, etc. Methods: Post in various controllers

Problem Type: 3. Logic Bug

Root Cause:

  • Throw HttpError(HttpStatusCode.NOT_FOUND, ...) สำหรับ duplicate name errors
  • ควรใช้ BAD_REQUEST หรือ CONFLICT แทน

Code ปัจจุบัน (ผิด):

if (checkName) {
  throw new HttpError(HttpStatusCode.NOT_FOUND, "ชื่อนี้มีอยู่ในระบบแล้ว"); // ❌ Wrong status code
}

Recommended Fix:

if (checkName) {
  throw new HttpError(HttpStatusCode.CONFLICT, "ชื่อนี้มีอยู่ในระบบแล้ว"); // ✅ Correct status code
}

#12 - Typos in Status Field (BUG)

File & Location: ChangePositionController.ts:79 Method: CreateChangePosition

Problem Type: 3. Logic Bug

Root Cause:

  • Status เป็น "WAITTING" (ตัว T เกิน)
  • ควรเป็น "WAITING"

Code ปัจจุบัน (ผิด):

changePosition.status = "WAITTING"; // ❌ typo

Recommended Fix:

changePosition.status = "WAITING"; // ✅ correct spelling

หมายเหตุ: ปัญหาเดียวกันนี้พบใน ChangePositionController.ts:241


สรุปคำแนะนำการแก้ไขแบบรวม

ระดับความสำคัญ

ต้องแก้ทันที (P0 - Critical):

  1. QueryRunner transaction not released on error - อาจทำให้ connection leak

ควรแก้โดยเร็ว (P1 - High): 2. Promise.all operations without error handling - unhandled rejections 3. QueryBuilder without validation and error handling

ควรแก้ (P2 - Medium): 4. Null reference checks 5. Unsafe optional chain usage 6. Promise operations in edit methods

แก้เมื่อว่าง (P3 - Low): 7. Hardcoded data 8. Missing transaction for related operations

แนวทางการแก้ไขแบบ Global

  1. Use try-finally pattern for all QueryRunner operations
  2. Add input validation for all query parameters
  3. Use Promise.allSettled หรือ wrap promises in try-catch
  4. Implement proper null checks ก่อน accessing nested properties
  5. Use transactions สำหรับ operations ที่ต้องการ consistency

ไฟล์ที่ต้องแก้ไข

  1. src/controllers/CommandOperatorController.ts - Transaction handling, Promise operations
  2. src/controllers/DevelopmentRequestController.ts - Promise.all, QueryBuilder validation
  3. src/controllers/DPISController.ts - Null reference checks
  4. src/controllers/CommandSalaryController.ts - Input validation
  5. src/controllers/CommandTypeController.ts - Error status codes, hardcoded data

ข้อมูลเพิ่มเติม

  • Controllers ที่ยังไม่ได้ตรวจสอบ: 120 ไฟล์
  • จุดเสี่ยงที่พบซ้ำจากชุดที่ 1: Promise.all without error handling, QueryBuilder without error handling

รายงานนี้ถูกสร้างโดย AI Code Review System สำหรับ BMA EHR Organization Project