From e69320005cdcf9a48a892d9b684497ec598873c5 Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Thu, 9 Jul 2026 21:45:30 +0700 Subject: [PATCH 01/12] fixed service stop working --- src/app.ts | 18 ++++++++++++++++++ src/controllers/PermissionController.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index c2cbe5f7..2a75e6fe 100644 --- a/src/app.ts +++ b/src/app.ts @@ -21,6 +21,24 @@ import { DateSerializer } from "./interfaces/date-serializer"; import { initWebSocket } from "./services/webSocket"; import { RetirementService } from "./services/RetirementService"; +// ── Process-level safety nets ────────────────────────────────────────────── +// ตาข่ายกัน service ตายจาก promise ที่ reject แล้วไม่ถูกจับ / exception ที่หลุด +// ออกมา (เช่น bug เรียก async โดยไม่ await ใน PermissionController). +// ลงทะเบียนไว้ให้เร็วที่สุด ก่อนที่ main() จะเริ่มทำงาน +process.on("unhandledRejection", (reason) => { + console.error( + "[APP][unhandledRejection] A promise was rejected but never caught:", + reason, + ); +}); +process.on("uncaughtException", (err) => { + // คง service ไว้เพื่อ availability สูงสุด + // หมายเหตุ: process อาจอยู่ในสถานะไม่สม่ำเสมอ ควร monitor log นี้และ restart + // ในช่วงเวลาที่เหมาะสม ถ้าต้องการ crash-on-error ให้เปลี่ยนเป็น graceful + // shutdown + process.exit(1) ตรงนี้ + console.error("[APP][uncaughtException] An exception escaped all handlers:", err); +}); + async function main() { await AppDataSource.initialize(); diff --git a/src/controllers/PermissionController.ts b/src/controllers/PermissionController.ts index 44747b09..f7a22517 100644 --- a/src/controllers/PermissionController.ts +++ b/src/controllers/PermissionController.ts @@ -810,7 +810,7 @@ export class PermissionController extends Controller { }); const getAsync = promisify(redisClient.get).bind(redisClient); - let org = this.PermissionOrg(request, system, action); + let org = await this.PermissionOrg(request, system, action); let reply = await getAsync("user_" + id); if (reply != null) { reply = JSON.parse(reply); From af88dffbbea50b8dabdbe8af2079f7167bbb9569 Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Tue, 14 Jul 2026 08:35:52 +0700 Subject: [PATCH 02/12] log and fix bug act role & menu --- src/controllers/PosMasterActController.ts | 49 +++++++++++++++++++---- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/controllers/PosMasterActController.ts b/src/controllers/PosMasterActController.ts index 3b5d3196..4d328f99 100644 --- a/src/controllers/PosMasterActController.ts +++ b/src/controllers/PosMasterActController.ts @@ -325,16 +325,33 @@ export class PosMasterActController extends Controller { // ลบ Redis cache ของคนที่เป็น acting if (posMasterAct != null && posMasterAct.posMasterChild?.current_holderId) { const profileId = posMasterAct.posMasterChild.current_holderId; - const redisClient = await this.redis.createClient({ + const redisClient = this.redis.createClient({ host: REDIS_HOST, port: REDIS_PORT, }); const delAsync = promisify(redisClient.del).bind(redisClient); - await delAsync("role_" + profileId); - await delAsync("menu_" + profileId); + const quitAsync = promisify(redisClient.quit).bind(redisClient); + try { + const [roleDeleted, menuDeleted] = await Promise.all([ + delAsync("role_" + profileId), + delAsync("menu_" + profileId), + ]); - redisClient.quit(); + if (roleDeleted === 0) { + console.warn(`[PosMasterActController] Redis key not found: role_${profileId}`); + } + if (menuDeleted === 0) { + console.warn(`[PosMasterActController] Redis key not found: menu_${profileId}`); + } + } catch (error) { + console.error( + `[PosMasterActController] Redis delete error for profile ${profileId}:`, + error, + ); + } finally { + await quitAsync(); + } } return new HttpSuccess(); @@ -868,10 +885,28 @@ export class PosMasterActController extends Controller { }); const delAsync = promisify(redisClient.del).bind(redisClient); - await delAsync("role_" + profileId); - await delAsync("menu_" + profileId); + const quitAsync = promisify(redisClient.quit).bind(redisClient); - redisClient.quit(); + try { + const [roleDeleted, menuDeleted] = await Promise.all([ + delAsync("role_" + profileId), + delAsync("menu_" + profileId), + ]); + + if (roleDeleted === 0) { + console.warn(`[PosMasterActController] Redis key not found: role_${profileId}`); + } + if (menuDeleted === 0) { + console.warn(`[PosMasterActController] Redis key not found: menu_${profileId}`); + } + } catch (error) { + console.error( + `[PosMasterActController] Redis delete error for profile ${profileId}:`, + error, + ); + } finally { + await quitAsync(); + } }), ); } From cd057bf81d9fed3bc738dac757e5eee824a7a844 Mon Sep 17 00:00:00 2001 From: harid Date: Tue, 14 Jul 2026 11:41:04 +0700 Subject: [PATCH 03/12] =?UTF-8?q?=E0=B9=81=E0=B8=81=E0=B9=89=E0=B8=9B?= =?UTF-8?q?=E0=B8=B1=E0=B8=8D=E0=B8=AB=E0=B8=B2=20password=20inconsistency?= =?UTF-8?q?=20=E0=B8=A3=E0=B8=B0=E0=B8=AB=E0=B8=A7=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=87=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B8=AA=E0=B8=A3=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B8=87=20user=20=E0=B9=83=E0=B8=AB=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=81=E0=B8=B1=E0=B8=9A=20reset-password=20endpoin?= =?UTF-8?q?t=20=E0=B9=82=E0=B8=94=E0=B8=A2=E0=B9=83=E0=B8=8A=E0=B9=89=20bi?= =?UTF-8?q?rthDate=20=E0=B8=88=E0=B8=B2=E0=B8=81=20profile=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=20save=20=E0=B9=81=E0=B8=A5=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B9=81=E0=B8=97=E0=B8=99=20input=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/keycloak/user-operations.ts | 204 +++++++++++++++++++ src/services/ExecuteOfficerProfileService.ts | 178 ++++------------ 2 files changed, 246 insertions(+), 136 deletions(-) create mode 100644 src/keycloak/user-operations.ts diff --git a/src/keycloak/user-operations.ts b/src/keycloak/user-operations.ts new file mode 100644 index 00000000..e6f50142 --- /dev/null +++ b/src/keycloak/user-operations.ts @@ -0,0 +1,204 @@ +/** + * Helper functions for Keycloak user operations + * + * สร้างเพื่อแยก Keycloak operations ออกจาก DB transaction + * และใช้ profile data ที่ save แล้วแทน input data + */ + +import { + createUser, + addUserRoles, + removeUserRoles, + updateUserAttributes, + getUserByUsername, + getRoleMappings, +} from "./index"; +import { Profile } from "../entities/Profile"; +import HttpError from "../interfaces/http-error"; +import HttpStatusCode from "../interfaces/http-status"; + +/** + * สร้าง password จาก profile.birthDate เหมือน reset-password endpoint + * + * @param profile - Profile object ที่มี birthDate + * @returns password string ในรูปแบบ ddmmyyyy (เช่น "31122563") + * + * Reference: UserController.ts reset-password (บรรทัด 867-876) + */ +export function generatePasswordFromProfile(profile: Profile): string { + if (!profile.birthDate) { + throw new Error("Profile birthDate is required for password generation"); + } + + const _date = new Date(profile.birthDate.toDateString()) + .getDate() + .toString() + .padStart(2, "0"); + const _month = ( + new Date(profile.birthDate.toDateString()).getMonth() + 1 + ) + .toString() + .padStart(2, "0"); + const _year = new Date(profile.birthDate.toDateString()).getFullYear() + 543; + + return `${_date}${_month}${_year}`; +} + +/** + * Sync Keycloak user สำหรับ profile ที่ save แล้ว + * + * รวมทุก Keycloak operations: + * - ตรวจสอบว่ามี user อยู่แล้วหรือไม่ + * - สร้าง user ใหม่ (ถ้ายังไม่มี) ด้วย password จาก profile.birthDate + * - กำหนด role USER + * - อัปเดต attributes (profileId, prefix) + * + * @param profile - Profile object ที่ถูก save แล้ว (ต้องมี id และ birthDate) + * @param roleList - List of roles จาก Keycloak (ผลลัพธ์จาก getRoles()) + * @returns userKeycloakId string หรือ throws HttpError + * + * @throws HttpError ถ้า createUser หรือ role operations ล้มเหลว + */ +export async function syncKeycloakForProfile( + profile: Profile, + roleList: any[], +): Promise { + console.log( + `[syncKeycloakForProfile] Starting for citizenId: ${profile.citizenId}, profileId: ${profile.id}`, + ); + + if (!profile.id) { + throw new Error("Profile ID is required for Keycloak sync"); + } + + // ตรวจสอบว่ามี user อยู่แล้วหรือไม่ + const checkUser = await getUserByUsername(profile.citizenId); + console.log( + `[syncKeycloakForProfile] Keycloak user exists: ${checkUser.length > 0}`, + ); + + let userKeycloakId: string; + + if (checkUser.length === 0) { + // สร้าง user ใหม่ + console.log( + `[syncKeycloakForProfile] Creating new Keycloak user for citizenId: ${profile.citizenId}`, + ); + + // สร้าง password จาก profile.birthDate (NOT input birthDate) + const password = generatePasswordFromProfile(profile); + + console.log( + `[syncKeycloakForProfile] Generated password from profile.birthDate: ${profile.birthDate}`, + ); + + // กรอง "." ออกจาก firstName ก่อนส่งไป keycloak + const sanitizedFirstName = profile.firstName?.replace(/\./g, "") ?? ""; + + console.log( + `[syncKeycloakForProfile] Creating user - firstName: ${sanitizedFirstName}, lastName: ${profile.lastName}`, + ); + + const createUserResult: any = await createUser(profile.citizenId, password, { + firstName: sanitizedFirstName, + lastName: profile.lastName ?? "", + }); + + // ตรวจสอบ createUser error + if ( + createUserResult && + typeof createUserResult === "object" && + createUserResult.errorMessage + ) { + console.error( + `[syncKeycloakForProfile] createUser FAILED - field: ${createUserResult.field}, errorMessage: ${createUserResult.errorMessage}`, + ); + throw new HttpError( + HttpStatusCode.BAD_REQUEST, + `Keycloak validation failed: ${createUserResult.field} - ${createUserResult.errorMessage}`, + ); + } + + userKeycloakId = createUserResult; + + console.log( + `[syncKeycloakForProfile] User created successfully, userKeycloakId: ${userKeycloakId}`, + ); + } else { + // ใช้ user ที่มีอยู่แล้ว + console.log( + `[syncKeycloakForProfile] Using existing Keycloak user, userKeycloakId: ${checkUser[0].id}`, + ); + userKeycloakId = checkUser[0].id; + + // ลบ roles เดิมแล้วกำหนด role USER ใหม่ + const rolesData = await getRoleMappings(userKeycloakId); + if (rolesData) { + const _delRole = rolesData.map((x: any) => ({ + id: x.id, + name: x.name, + })); + console.log( + `[syncKeycloakForProfile] Removing old roles: ${_delRole.length}`, + ); + await removeUserRoles(userKeycloakId, _delRole); + } + } + + // กำหนด role USER + console.log(`[syncKeycloakForProfile] Assigning USER role`); + const result = await addUserRoles( + userKeycloakId, + roleList + .filter((v) => v.name === "USER") + .map((x) => ({ + id: x.id, + name: x.name, + })), + ); + + console.log(`[syncKeycloakForProfile] USER role assigned, result: ${result}`); + + // อัปเดต attributes + console.log(`[syncKeycloakForProfile] Updating user attributes`); + await updateUserAttributes(userKeycloakId, { + profileId: [profile.id], + prefix: [profile.prefix || ""], + }); + + console.log( + `[syncKeycloakForProfile] Completed successfully for citizenId: ${profile.citizenId}`, + ); + + return userKeycloakId; +} + +/** + * จัดการ error จาก Keycloak operations + * + * @param error - Error object จาก Keycloak operations + * @param context - Context object { citizenId, profileId, operation } + * + * @returns HttpError ที่ formatted สำหรับ throw กลับไป + */ +export function handleKeycloakError( + error: any, + context: { + citizenId?: string; + profileId?: string; + operation: string; + }, +): HttpError { + console.error( + `[KeycloakError] ${context.operation} failed for citizenId: ${context.citizenId}, profileId: ${context.profileId}`, + error, + ); + + const errorMessage = + error instanceof Error ? error.message : "Unknown Keycloak error"; + + return new HttpError( + HttpStatusCode.INTERNAL_SERVER_ERROR, + `Keycloak ${context.operation} failed: ${errorMessage}. Profile created but Keycloak sync failed - manual fix required.`, + ); +} diff --git a/src/services/ExecuteOfficerProfileService.ts b/src/services/ExecuteOfficerProfileService.ts index f06159dc..855863c4 100644 --- a/src/services/ExecuteOfficerProfileService.ts +++ b/src/services/ExecuteOfficerProfileService.ts @@ -38,15 +38,11 @@ import { removeProfileInOrganize, setLogDataDiff, } from "../interfaces/utils"; +import { getRoles } from "../keycloak"; import { - addUserRoles, - createUser, - getRoleMappings, - getRoles, - getUserByUsername, - removeUserRoles, - updateUserAttributes, -} from "../keycloak"; + syncKeycloakForProfile, + handleKeycloakError, +} from "../keycloak/user-operations"; import { CreatePosMasterHistoryOfficer } from "./PositionService"; import { getOrgFullName, getPosMasterNo } from "../utils/org-formatting"; import CallAPI from "../interfaces/call-api"; @@ -141,8 +137,8 @@ export interface ExecutionContext { * ถ้าทุกคนสำเร็จจะ return result รายงาน success count * * ⚠️ หมายเหตุ Keycloak: operations (createUser/addUserRoles/removeUserRoles/updateUserAttributes) - * ทำภายใน transaction เพื่อ preserve behavior เดิม — Keycloak ไม่สามารถ rollback ได้ - * ถ้า DB rollback หลังจาก Keycloak operation สำเร็จ → Keycloak จะถูกเปลี่ยนไปแล้ว + * ทำหลังจาก DB transaction เพื่อใช้ profile.birthDate ที่ save แล้ว + * Keycloak ไม่สามารถ rollback ได้ - ถ้า sync ล้มเหลว profile จะถูกสร้างแต่ไม่มี Keycloak user * * Design note: แก้ปัญหา "Circular Dependency" ระหว่าง API Org กับ API บรรจุ โดยให้ฝั่งบรรจุ * ส่ง resultData กลับมา แล้วฝั่ง Org ประมวลผลสร้าง profile เองที่ต้นทาง แทนการเรียกซ้อนกัน @@ -485,117 +481,6 @@ export class ExecuteOfficerProfileService { ? _null : calculateRetireLaw(item.bodyProfile.birthDate); - let userKeycloakId: any; - let result: any; - console.log( - "[ExecuteOfficerProfileService] Checking Keycloak user for citizenId:", - item.bodyProfile.citizenId, - ); - const checkUser = await getUserByUsername(item.bodyProfile.citizenId); - console.log( - "[ExecuteOfficerProfileService] Keycloak user exists:", - checkUser.length > 0, - ); - if (checkUser.length == 0) { - console.log("[ExecuteOfficerProfileService] Creating new Keycloak user"); - let password = item.bodyProfile.citizenId; - if (item.bodyProfile.birthDate != null) { - const _date = new Date(item.bodyProfile.birthDate.toDateString()) - .getDate() - .toString() - .padStart(2, "0"); - const _month = ( - new Date(item.bodyProfile.birthDate.toDateString()).getMonth() + 1 - ) - .toString() - .padStart(2, "0"); - const _year = new Date(item.bodyProfile.birthDate.toDateString()).getFullYear() + 543; - password = `${_date}${_month}${_year}`; - } - console.log( - "[ExecuteOfficerProfileService] Calling createUser for:", - item.bodyProfile.citizenId, - ); - console.log( - "[ExecuteOfficerProfileService] createUser data - firstName:", - item.bodyProfile.firstName, - "lastName:", - item.bodyProfile.lastName, - ); - // กรอง "." ออกจาก firstName ก่อนส่งไป keycloak (ป้องกัน . หรืออักขระอื่นๆ) - const sanitizedFirstName = item.bodyProfile.firstName?.replace(/\./g, "") ?? ""; - userKeycloakId = await createUser(item.bodyProfile.citizenId, password, { - firstName: sanitizedFirstName, - lastName: item.bodyProfile.lastName, - }); - if ( - userKeycloakId && - typeof userKeycloakId === "object" && - userKeycloakId.errorMessage - ) { - console.error( - "[ExecuteOfficerProfileService] createUser FAILED - field:", - userKeycloakId.field, - "errorMessage:", - userKeycloakId.errorMessage, - "params:", - userKeycloakId.params, - ); - throw new HttpError( - HttpStatusCode.BAD_REQUEST, - `Keycloak validation failed: ${userKeycloakId.field} - ${userKeycloakId.errorMessage}`, - ); - } - console.log( - "[ExecuteOfficerProfileService] User created in Keycloak, userKeycloakId:", - userKeycloakId, - ); - result = await addUserRoles( - userKeycloakId, - list - .filter((v) => v.name === "USER") - .map((x) => ({ - id: x.id, - name: x.name, - })), - ); - console.log( - "[ExecuteOfficerProfileService] USER role assigned to new user, result:", - result, - ); - } else { - console.log("[ExecuteOfficerProfileService] Updating existing Keycloak user"); - userKeycloakId = checkUser[0].id; - console.log( - "[ExecuteOfficerProfileService] Existing userKeycloakId:", - userKeycloakId, - ); - const rolesData = await getRoleMappings(userKeycloakId); - if (rolesData) { - const _delRole = rolesData.map((x: any) => ({ - id: x.id, - name: x.name, - })); - console.log( - "[ExecuteOfficerProfileService] Removing old roles:", - _delRole.length, - ); - await removeUserRoles(userKeycloakId, _delRole); - } - result = await addUserRoles( - userKeycloakId, - list - .filter((v) => v.name === "USER") - .map((x) => ({ - id: x.id, - name: x.name, - })), - ); - console.log( - "[ExecuteOfficerProfileService] USER role assigned to existing user", - ); - } - let profile: any = await profileRepository.findOne({ where: { citizenId: item.bodyProfile.citizenId /*, isActive: true */ }, relations: ["roleKeycloaks", "profileInsignias", "profileAvatars"], @@ -673,9 +558,8 @@ export class ExecuteOfficerProfileService { profile = Object.assign({ ...item.bodyProfile, ...meta }); profile.dateRetire = _dateRetire; profile.dateRetireLaw = _dateRetireLaw; - profile.roleKeycloaks = result && roleKeycloak ? [roleKeycloak] : []; - profile.keycloak = - userKeycloakId && typeof userKeycloakId === "string" ? userKeycloakId : ""; + profile.roleKeycloaks = roleKeycloak ? [roleKeycloak] : []; + profile.keycloak = ""; // ว่างไว้ก่อน - จะ sync Keycloak หลังจาก save แล้ว profile.registrationAddress = item.bodyProfile.registrationAddress; profile.registrationProvinceId = registrationProvinceId ? registrationProvinceId.id @@ -718,12 +602,6 @@ export class ExecuteOfficerProfileService { "[ExecuteOfficerProfileService] New profile saved, profileId:", profile.id, ); - // update user attribute in keycloak - await updateUserAttributes(profile.keycloak ?? "", { - profileId: [profile.id], - prefix: [profile.prefix || ""], - }); - console.log("[ExecuteOfficerProfileService] Keycloak attributes updated"); setLogDataDiff(req, { before, after: profile }); } //ขรก.ในระบบ หรือ ขรก.ในระบบที่สถานะพ้นจากราชการ @@ -753,9 +631,8 @@ export class ExecuteOfficerProfileService { profile = Object.assign({ ...item.bodyProfile, ...meta }); profile.dateRetire = _dateRetire; profile.dateRetireLaw = _dateRetireLaw; - profile.roleKeycloaks = result && roleKeycloak ? [roleKeycloak] : []; - profile.keycloak = - userKeycloakId && typeof userKeycloakId === "string" ? userKeycloakId : ""; + profile.roleKeycloaks = roleKeycloak ? [roleKeycloak] : []; + profile.keycloak = ""; // ว่างไว้ก่อน - จะ sync Keycloak หลังจาก save แล้ว profile.registrationAddress = item.bodyProfile.registrationAddress; profile.registrationProvinceId = registrationProvinceId ? registrationProvinceId.id @@ -800,9 +677,8 @@ export class ExecuteOfficerProfileService { console.log( "[ExecuteOfficerProfileService] Updating existing active profile", ); - profile.roleKeycloaks = result && roleKeycloak ? [roleKeycloak] : []; - profile.keycloak = - userKeycloakId && typeof userKeycloakId === "string" ? userKeycloakId : ""; + profile.roleKeycloaks = roleKeycloak ? [roleKeycloak] : []; + profile.keycloak = ""; // ว่างไว้ก่อน - จะ sync Keycloak หลังจาก save แล้ว profile.isProbation = item.bodyProfile.isProbation; profile.isLeave = item.bodyProfile.isLeave; profile.isRetirement = false; @@ -894,6 +770,36 @@ export class ExecuteOfficerProfileService { } } + // ───────────────────────────────────────────────────────────── + // Keycloak Operations - ทำหลังจาก profile save สำเร็จแล้ว + // ใช้ profile.birthDate ที่ save แล้ว เพื่อให้ตรงกับ reset-password endpoint + // ───────────────────────────────────────────────────────────── + if (profile && profile.id && !profile.keycloak) { + console.log( + "[ExecuteOfficerProfileService] Syncing Keycloak user for profileId:", + profile.id, + ); + try { + const userKeycloakId = await syncKeycloakForProfile(profile, list); + profile.keycloak = userKeycloakId; + await profileRepository.save(profile); + console.log( + "[ExecuteOfficerProfileService] Keycloak sync completed, profile.keycloak updated:", + profile.keycloak, + ); + } catch (error) { + console.error( + `[ExecuteOfficerProfileService] Keycloak sync failed for profileId: ${profile.id}, citizenId: ${profile.citizenId}`, + error, + ); + throw handleKeycloakError(error, { + citizenId: profile.citizenId, + profileId: profile.id, + operation: "sync", + }); + } + } + if (profile && profile.id) { console.log( "[ExecuteOfficerProfileService] Processing additional data for profileId:", From ac9dc93d1e3fcff5863e5107c6bf761817bcd6cb Mon Sep 17 00:00:00 2001 From: harid Date: Fri, 17 Jul 2026 13:14:36 +0700 Subject: [PATCH 04/12] fix GET user-oc > return dnaId --- .../OrganizationDotnetController.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/controllers/OrganizationDotnetController.ts b/src/controllers/OrganizationDotnetController.ts index 0814d8a2..3a03f56a 100644 --- a/src/controllers/OrganizationDotnetController.ts +++ b/src/controllers/OrganizationDotnetController.ts @@ -4792,7 +4792,16 @@ export class OrganizationDotnetController extends Controller { async getProfileByKeycloak(@Path() keycloakId: string) { const profile = await this.profileRepo.findOne({ where: { keycloak: keycloakId }, - relations: ["posLevel", "posType", "current_holders", "current_holders.orgRoot"], + relations: [ + "posLevel", + "posType", + "current_holders", + "current_holders.orgRoot", + "current_holders.orgChild1", + "current_holders.orgChild2", + "current_holders.orgChild3", + "current_holders.orgChild4", + ], }); if (!profile) { throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบข้อมูลบุคคลนี้ในระบบ"); @@ -4807,11 +4816,16 @@ export class OrganizationDotnetController extends Controller { throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบแบบร่างโครงสร้าง"); } - const root = - profile.current_holders == null || - profile.current_holders.find((x) => x.orgRevisionId == orgRevisionPublish.id)?.orgRoot == null + const holder = + profile.current_holders == null ? null - : profile.current_holders.find((x) => x.orgRevisionId == orgRevisionPublish.id)?.orgRoot; + : profile.current_holders.find((x) => x.orgRevisionId == orgRevisionPublish.id) ?? null; + + const root = holder?.orgRoot ?? null; + const child1 = holder?.orgChild1 ?? null; + const child2 = holder?.orgChild2 ?? null; + const child3 = holder?.orgChild3 ?? null; + const child4 = holder?.orgChild4 ?? null; const _profile: any = { profileId: profile.id, @@ -4828,6 +4842,10 @@ export class OrganizationDotnetController extends Controller { root: root == null ? null : root.orgRootName, rootShortName: root == null ? null : root.orgRootShortName, rootDnaId: root == null ? null : root.ancestorDNA, + child1DnaId: child1 == null ? null : child1.ancestorDNA, + child2DnaId: child2 == null ? null : child2.ancestorDNA, + child3DnaId: child3 == null ? null : child3.ancestorDNA, + child4DnaId: child4 == null ? null : child4.ancestorDNA, }; return new HttpSuccess(_profile); } From 880c2499750c123d96f7ca04c9a366c5a00d38c0 Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Fri, 17 Jul 2026 17:43:42 +0700 Subject: [PATCH 05/12] fixed remove redis permission --- src/controllers/UserController.ts | 45 +++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts index 4902ce0f..86b44ef4 100644 --- a/src/controllers/UserController.ts +++ b/src/controllers/UserController.ts @@ -47,6 +47,11 @@ import { RoleKeycloak } from "../entities/RoleKeycloak"; import { addLogSequence } from "../interfaces/utils"; import { OrgRevision } from "../entities/OrgRevision"; import { Uuid } from "@elastic/elasticsearch/lib/api/types"; +import { promisify } from "util"; + +const REDIS_HOST = process.env.REDIS_HOST; +const REDIS_PORT = process.env.REDIS_PORT; +const redis = require("redis"); // import * as io from "../lib/websocket"; // import elasticsearch from "../elasticsearch"; // import { StorageFolder } from "../interfaces/storage-fs"; @@ -352,6 +357,8 @@ export class KeycloakController extends Controller { where: { keycloak: userId }, relations: ["roleKeycloaks"], }); + let profileId: string | undefined; + if (!profile) { const profileEmp = await this.profileEmpRepo.findOne({ where: { keycloak: userId, employeeClass: "PERM" }, @@ -359,10 +366,12 @@ export class KeycloakController extends Controller { }); if (!profileEmp) throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบข้อมูล"); profileEmp.roleKeycloaks = profileEmp.roleKeycloaks.filter((x) => x.id != roleId); - this.profileEmpRepo.save(profileEmp); + await this.profileEmpRepo.save(profileEmp); + profileId = profileEmp.id; } else { profile.roleKeycloaks = profile.roleKeycloaks.filter((x) => x.id != roleId); - this.profileRepo.save(profile); + await this.profileRepo.save(profile); + profileId = profile.id; } const list = await getRoles(); @@ -374,6 +383,38 @@ export class KeycloakController extends Controller { list.filter((v) => roleId === v.id), ); if (!result) throw new Error("Failed. Cannot remove user's role."); + + if (profileId) { + let redisClient: any; + let quitAsync: any; + try { + redisClient = redis.createClient({ + host: REDIS_HOST, + port: REDIS_PORT, + }); + const delAsync = promisify(redisClient.del).bind(redisClient); + quitAsync = promisify(redisClient.quit).bind(redisClient); + + const [roleDeleted, menuDeleted] = await Promise.all([ + delAsync("role_" + profileId), + delAsync("menu_" + profileId), + ]); + + if (roleDeleted === 0) { + console.warn(`Redis key not found: role_${profileId}`); + } + if (menuDeleted === 0) { + console.warn(`Redis key not found: menu_${profileId}`); + } + } catch (error) { + console.error(`Failed to delete Redis cache for profile ${profileId}:`, error); + } finally { + if (quitAsync) { + await quitAsync(); + } + } + } + return new HttpSuccess(); } From 4ef29ad4db9e02568a0af0805fce85392734eecc Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Fri, 17 Jul 2026 18:23:08 +0700 Subject: [PATCH 06/12] fixed --- src/controllers/UserController.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts index 86b44ef4..b5add4be 100644 --- a/src/controllers/UserController.ts +++ b/src/controllers/UserController.ts @@ -38,6 +38,8 @@ import { import { AppDataSource } from "../database/data-source"; import { Profile } from "../entities/Profile"; import { ProfileEmployee } from "../entities/ProfileEmployee"; +import { PosMaster } from "../entities/PosMaster"; +import { EmployeePosMaster } from "../entities/EmployeePosMaster"; import { RequestWithUser } from "../middlewares/user"; import HttpSuccess from "../interfaces/http-success"; import { Brackets, In, IsNull, Not } from "typeorm"; @@ -70,6 +72,8 @@ function stripLeadingSlash(str: string) { export class KeycloakController extends Controller { private profileRepo = AppDataSource.getRepository(Profile); private profileEmpRepo = AppDataSource.getRepository(ProfileEmployee); + private posMasterRepository = AppDataSource.getRepository(PosMaster); + private employeePosMasterRepository = AppDataSource.getRepository(EmployeePosMaster); private roleKeycloakRepo = AppDataSource.getRepository(RoleKeycloak); @Get("user/{id}") @@ -367,7 +371,6 @@ export class KeycloakController extends Controller { if (!profileEmp) throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบข้อมูล"); profileEmp.roleKeycloaks = profileEmp.roleKeycloaks.filter((x) => x.id != roleId); await this.profileEmpRepo.save(profileEmp); - profileId = profileEmp.id; } else { profile.roleKeycloaks = profile.roleKeycloaks.filter((x) => x.id != roleId); await this.profileRepo.save(profile); @@ -385,6 +388,17 @@ export class KeycloakController extends Controller { if (!result) throw new Error("Failed. Cannot remove user's role."); if (profileId) { + try { + await this.posMasterRepository + .createQueryBuilder() + .update(PosMaster) + .set({ authRoleId: '' }) + .where("current_holderId = :profileId", { profileId }) + .execute(); + } catch (error) { + console.error(`Failed to clear authRoleId on position records for profile ${profileId}:`, error); + } + let redisClient: any; let quitAsync: any; try { From 26732420c227cc38143dc2fb2f4e5a7d11f1fbb7 Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Fri, 17 Jul 2026 21:12:59 +0700 Subject: [PATCH 07/12] Add CLAUDE.md with project architecture and command reference Documents the tsoa-driven routing, auth schemes, background jobs, and DB conventions so future Claude Code sessions can ramp up faster. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..79befb8f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,134 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +TypeScript REST API for **Bangkok Metropolitan Administration (BMA)** — a Human Resource +Management System (HRMS) module managing organization structure (โครงสร้างอัตรากำลัง), +positions, personnel profiles, salary/tenure calculations, and related HR workflows. + +**Stack:** Express.js · TypeORM (MySQL) · tsoa (controller-first OpenAPI generation) · Keycloak · +RabbitMQ · Redis · Elasticsearch · WebSocket (socket.io) · node-cron + +## Commands + +```sh +npm install # install deps (npm is canonical — CI/Docker use npm install, not pnpm) +npm run dev # dev server with hot reload (nodemon) +npm run build # tsoa spec-and-routes (regenerates src/routes.ts, src/swagger.json) + tsc +npm run check # tsc --noEmit +npm start # run compiled dist/app.js +npm run format # prettier --write . + +npm run migration:generate src/migration/ # generate a TypeORM migration +npm run migration:run # run pending migrations +node scripts/clean-migration-fk-idx.js # strip FK_/idx_ lines from generated migrations (run after every generate) + +npm test # jest +npm run test:watch # jest --watch +npm run test:coverage # jest --coverage +npx jest src/__tests__/unit/OrganizationController.spec.ts # run a single test file +``` + +A repo note (README) documents building/testing the release pipeline locally with `act` +(`act workflow_dispatch -W .github/workflows/release.yaml ...`), but the actual CI is Forgejo +Actions (`.forgejo/workflows/`) and an OneDev buildspec (`.onedev-buildspec.yml`); both run +`npm install && npm run build` and build `docker/Dockerfile`. + +**Note:** despite `pnpm-lock.yaml` being present in the repo, all CI/Docker paths use `npm`. Use +`npm` for scripts and dependency changes unless told otherwise. + +## Architecture + +### Request flow: tsoa controllers → (optional) services → TypeORM repositories + +- Routes are **not** hand-written. `tsoa.json` globs `src/controllers/**/*Controller.ts` and + `npm run build` regenerates `src/routes.ts` (Express route registration, called via + `RegisterRoutes(app)` in `src/app.ts`) and `src/swagger.json` from tsoa decorators. **Any time + you add/change a controller method or its decorators, you must run `npm run build`** (or at + least `tsoa spec-and-routes`) or the route/swagger changes won't take effect. +- In practice most controllers get their repositories directly via + `AppDataSource.getRepository(Entity)` in the constructor/method body rather than going through a + service layer — the codebase is controller-heavy (`OrganizationController.ts`, + `ImportDataController.ts`, `PositionController.ts`, `ReportController.ts` are each hundreds of + KB). `src/services/` exists for genuinely cross-cutting or heavier business logic (org command + execution, salary/tenure batch jobs, RabbitMQ, WebSocket) but is not a strict mandatory layer for + every endpoint. +- All entities (`src/entities/`) extend `EntityBase` (`src/entities/base/Base.ts`), which supplies + `id` (UUID), `createdAt`/`lastUpdatedAt`, and creator/updater id+name audit columns — every table + is audited by default. `src/entities/mis/` holds legacy/external MIS-system table mappings + (read-mostly, prefixed `HR_*`); `src/entities/view/` holds TypeORM entities mapped onto SQL + views (`viewCurrentTenure*`, `viewDirector*`, etc.) used for computed/reporting reads. +- Responses are wrapped in `HttpSuccess` (`src/interfaces/http-success.ts`, `{status, message, + result}`, Thai success message by default) or thrown as `HttpError` (`src/interfaces/ +http-error.ts`, carries an `HttpStatus` + Thai message) — the global error middleware + (`src/middlewares/error.ts`) catches thrown `HttpError`/exceptions and formats the response. + `src/interfaces/http-status.ts` is the status-code enum used everywhere instead of magic numbers. + +### Auth (three schemes, selected per-route via `@Security(...)`) + +All resolved in `expressAuthentication` (`src/middlewares/auth.ts`), which tsoa calls per-request +based on the controller's `@Security` decorator: + +- `bearerAuth` — Keycloak JWT in `Authorization: Bearer `. Verified either **offline** + (`AUTH_PUBLIC_KEY`, local RS256 verification via `fast-jwt`) or **online** (`AUTH_REALM_URL` + userinfo endpoint), selected by `AUTH_PREFERRED_MODE`. Populates `req.app.locals.logData` with + user/org-tree ids from the token for logging. Type: `RequestWithUser` (`src/middlewares/user.ts`). +- `webServiceAuth` — `X-API-Key` header, resolved against the `ApiKey` entity in + `src/middlewares/authWebService.ts` (looks up allowed `apiNames`/org scope). Type: + `RequestWithUserWebService`. +- `internalAuth` — `api-key`/`api_key`/`apikey` header checked against the `API_KEY` env var + (`src/middlewares/authInternal.ts`), for trusted internal services (e.g. the .NET HRMS system). +- If `NODE_ENV !== "production"` and `AUTH_BYPASS` is set, auth is skipped entirely + (`{preferred_username: "bypassed"}`) — dev/test convenience only. +- Role gating within `bearerAuth` routes uses `authRole()` (`src/middlewares/role.ts`) and the + `permission` helper (`src/interfaces/permission.ts`) which calls out to an external `/org/permission` + check via `CallAPI`. + +### Background work + +- **Cron jobs** are all registered inline in `src/app.ts` via `node-cron` (6-field: seconds + included), each wrapping a controller/service call in try/catch that only logs on failure — + daily org revision cache refresh, retirement status updates (Oct 1st), org DNA sync, tenure + recalculation, and posting retirement data to an external "Exprofile" system. +- **RabbitMQ** (`src/services/rabbitmq.ts`) publishes org-structure change events + (`sendToQueueOrg` / `sendToQueueOrgDraft`); connects with infinite retry + (`setTimeout(runMessageQueue, 1000)` on failure) — don't add redundant retry logic around it. + Fire-and-forget from callers; don't block HTTP responses on publish. +- **WebSocket** (`src/services/webSocket.ts`, `initWebSocket()`) for real-time push, initialized + before the HTTP server starts listening. +- **`OrgStructureCache`** (`src/utils/OrgStructureCache.ts`) is an in-memory TTL cache (30 min) for + org-tree reads, keyed by revision+root id, initialized/destroyed alongside the app lifecycle. Use + it instead of re-querying the same org structure repeatedly within a request. +- **`LogMemoryStore`** (`src/utils/LogMemoryStore.ts`) plus `src/middlewares/logs.ts` build a + per-request log sequence (including DB queries logged via the custom TypeORM `Logger` in + `src/database/data-source.ts`) — this is what's shipped to Elasticsearch for auditing. + +### Database + +- MySQL via TypeORM, connection pool configured in `src/database/data-source.ts` + (`connectionLimit`, `poolSize`, `maxQueryExecutionTime` all env-tunable). `synchronize` is + always `false` — schema changes go through migrations only. +- Timezone is pinned to Bangkok (`+07:00`) at the connection level; store/compare datetimes + accordingly rather than assuming UTC. +- After `migration:generate`, always run `node scripts/clean-migration-fk-idx.js` to strip + auto-generated `FK_*`/`idx_*` lines from the migration's `up`/`down` — this is a hard project + convention, not optional cleanup. + +### Data dictionary + +`docs/data-dictionary/` holds a generated schema data dictionary (`.docx`); the `data-dictionary` +skill / `scripts/generate-docx.py` regenerate it from `docs/data-dictionary/generate-prompt.md`. + +## Conventions + +- Files/classes: PascalCase (`OrganizationController.ts`). Variables/functions: camelCase. +- DB column comments and HTTP error/success messages are written in **Thai** — keep this + consistent when adding columns or throwing `HttpError`. +- Services must not import from `controllers/`; keep services HTTP-agnostic (no `HttpError`/ + `HttpSuccess` imports in `src/services/`). +- Don't use `moment` for new code (native `Date`/`Intl` only — `moment` remains for legacy call + sites). Don't bypass tsoa validation with manual `req.body` casting. +- `tsconfig.json` excludes `src/__tests__/**` and `*.spec.ts`/`*.test.ts` from the production + build; tests only run under `ts-jest` via `jest.config.js` (path alias `@/` → `src/`). From c356a0689902a708cab7434c3f7cddb1635b1403 Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Fri, 17 Jul 2026 21:21:37 +0700 Subject: [PATCH 08/12] fixed remove permission if role STAFF --- src/controllers/UserController.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts index b5add4be..c4b37b33 100644 --- a/src/controllers/UserController.ts +++ b/src/controllers/UserController.ts @@ -73,7 +73,6 @@ export class KeycloakController extends Controller { private profileRepo = AppDataSource.getRepository(Profile); private profileEmpRepo = AppDataSource.getRepository(ProfileEmployee); private posMasterRepository = AppDataSource.getRepository(PosMaster); - private employeePosMasterRepository = AppDataSource.getRepository(EmployeePosMaster); private roleKeycloakRepo = AppDataSource.getRepository(RoleKeycloak); @Get("user/{id}") @@ -387,12 +386,16 @@ export class KeycloakController extends Controller { ); if (!result) throw new Error("Failed. Cannot remove user's role."); - if (profileId) { + // delete authRoleId in posMaster if roleId is "f1fff8db-0795-47c1-9952-f3c18d5b6172" + if (profileId && roleId === "f1fff8db-0795-47c1-9952-f3c18d5b6172") { + let _null: any = null; + const authRoleId = _null; + console.log(`Clearing authRoleId on position records for profile ${profileId}`); try { await this.posMasterRepository .createQueryBuilder() .update(PosMaster) - .set({ authRoleId: '' }) + .set({ authRoleId: authRoleId }) .where("current_holderId = :profileId", { profileId }) .execute(); } catch (error) { From d10b7e11f0da86fe18277765f455f84d4492a845 Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Tue, 21 Jul 2026 09:08:24 +0700 Subject: [PATCH 09/12] fixed: clientSecret exprofile --- src/controllers/ExRetirementController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/ExRetirementController.ts b/src/controllers/ExRetirementController.ts index 6720f19d..b2604ebb 100644 --- a/src/controllers/ExRetirementController.ts +++ b/src/controllers/ExRetirementController.ts @@ -23,7 +23,7 @@ interface CachedToken { } const API_URL_BANGKOK = "https://exprofile.bangkok.go.th/API"; const clientId = "e5f6ad6ce374177eef023bf5d0c018b6"; -const clientSecret = "5EhOvN5DwHOKakupqT9FmCk7MOwpT3zLqLPkPh4ZhJpxBN2nMG@2022"; +const clientSecret = "5EhOvN5DwHOKakupqT9FmCk7MOwpT3zLqLPkPh4ZhJpxBN2nMG"; class TokenCache { private static cache: Map = new Map(); From d3e1707cc23d74af1be57febe834a02201eacc8d Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Tue, 21 Jul 2026 09:15:02 +0700 Subject: [PATCH 10/12] add log connect exprofile --- src/controllers/ExRetirementController.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/controllers/ExRetirementController.ts b/src/controllers/ExRetirementController.ts index b2604ebb..6a19ddfc 100644 --- a/src/controllers/ExRetirementController.ts +++ b/src/controllers/ExRetirementController.ts @@ -97,6 +97,8 @@ export class ExRetirementController extends Controller { retryCount++; continue; } + // log error message + console.error('getData getOfficerRetireData error:', error); throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "ไม่สามารถติดต่อ API ได้"); } } @@ -139,6 +141,7 @@ export class ExRetirementController extends Controller { retryCount++; continue; } + console.error('getData document error:', error); throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "ไม่สามารถติดต่อ API ได้"); } } @@ -168,6 +171,7 @@ async function getToken(ClientID: string, ClientSecret: string): Promise TokenCache.set(cacheKey, token); return token; } catch (error) { + console.error('getToken error:', error); return Promise.reject({ message: "Error occurred", error }); } } @@ -251,6 +255,7 @@ export async function PostRetireToExprofile( }); } + console.error('importOfficerRetireData error', error); throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "ไม่สามารถติดต่อ API ได้"); } } From 22ea54aeb375e4cfac3a6840de746b6ce8265539 Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Wed, 22 Jul 2026 22:20:07 +0700 Subject: [PATCH 11/12] fixed getaddrinfo EAI_AGAIN exprofile.bangkok.go.th --- src/controllers/ExRetirementController.ts | 53 +++++++++++++++++++---- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/controllers/ExRetirementController.ts b/src/controllers/ExRetirementController.ts index 6a19ddfc..c9c45dae 100644 --- a/src/controllers/ExRetirementController.ts +++ b/src/controllers/ExRetirementController.ts @@ -43,6 +43,35 @@ class TokenCache { } } +// network/DNS error codes ที่ถือว่าเป็น transient (ควร retry) +const TRANSIENT_NETWORK_CODES = [ + "EAI_AGAIN", // DNS lookup ล้มเหลวชั่วคราว + "ENOTFOUND", + "ECONNRESET", + "ETIMEDOUT", + "ECONNREFUSED", + "EHOSTUNREACH", + "ENETUNREACH", +]; + +// ตรวจว่า error ควร retry: ครอบ network/DNS (ไม่มี response หรือ code ตรง TRANSIENT) และ HTTP 5xx +function isTransientError(error: any): boolean { + if (!error) return false; + if (!error.response || TRANSIENT_NETWORK_CODES.includes(error.code)) { + return true; + } + return error.response?.status >= 500; +} + +// หน่วงเวลา (backoff) ระหว่าง retry — ใช้รูปแบบ setTimeout เดียวกับ keycloak/index.ts +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// config retry สำหรับเรียก Exprofile +const EXPROFILE_MAX_RETRIES = 3; // จากเดิม 2 +const EXPROFILE_BACKOFF_BASE_MS = 1000; // backoff = base * 2^retryCount → 1s, 2s + @Route("api/v1/org/ex/retirement") @Tags("ExRetirement") @Security("bearerAuth") @@ -60,7 +89,7 @@ export class ExRetirementController extends Controller { }, ) { let retryCount = 0; - const maxRetries = 2; + const maxRetries = EXPROFILE_MAX_RETRIES; while (retryCount < maxRetries) { try { @@ -92,8 +121,9 @@ export class ExRetirementController extends Controller { // return res.data; return new HttpSuccess(res.data.data); } catch (error: any) { - if (error.response?.status === 500 && retryCount < maxRetries - 1) { + if (isTransientError(error) && retryCount < maxRetries - 1) { TokenCache.delete(`${clientId}:${clientSecret}`); + await sleep(EXPROFILE_BACKOFF_BASE_MS * 2 ** retryCount); retryCount++; continue; } @@ -107,7 +137,7 @@ export class ExRetirementController extends Controller { @Get("/document/{documentId}") async getDocument(@Path("documentId") officerDocumentID: string, @Request() req: any) { let retryCount = 0; - const maxRetries = 2; + const maxRetries = EXPROFILE_MAX_RETRIES; while (retryCount < maxRetries) { try { const token = await getToken(clientId, clientSecret); @@ -136,8 +166,9 @@ export class ExRetirementController extends Controller { return; } } catch (error: any) { - if (error.response?.status === 500 && retryCount < maxRetries - 1) { + if (isTransientError(error) && retryCount < maxRetries - 1) { TokenCache.delete(`${clientId}:${clientSecret}`); + await sleep(EXPROFILE_BACKOFF_BASE_MS * 2 ** retryCount); retryCount++; continue; } @@ -171,8 +202,13 @@ async function getToken(ClientID: string, ClientSecret: string): Promise TokenCache.set(cacheKey, token); return token; } catch (error) { - console.error('getToken error:', error); - return Promise.reject({ message: "Error occurred", error }); + // log แบบกระชับ (ลด log noise จากการ dump object ใหญ่ตามที่เห็นใน log จริง) + console.error( + "getToken error:", + error instanceof Error ? `${error.name}: ${error.message}` : error, + ); + // โยน AxiosError ตัวจริงออกไปให้ caller อ่าน code / response / status เพื่อตัดสินใจ retry + throw error; } } @@ -198,7 +234,7 @@ export async function PostRetireToExprofile( } let retryCount = 0; - const maxRetries = 2; + const maxRetries = EXPROFILE_MAX_RETRIES; while (retryCount < maxRetries) { try { @@ -235,8 +271,9 @@ export async function PostRetireToExprofile( return res.data; } catch (error: any) { - if (error.response?.status === 500 && retryCount < maxRetries - 1) { + if (isTransientError(error) && retryCount < maxRetries - 1) { TokenCache.delete(`${clientId}:${clientSecret}`); + await sleep(EXPROFILE_BACKOFF_BASE_MS * 2 ** retryCount); retryCount++; continue; } From 02c30c558ae5d254e93e2aac63dd3c70aefb71ec Mon Sep 17 00:00:00 2001 From: harid Date: Thu, 23 Jul 2026 13:53:05 +0700 Subject: [PATCH 12/12] fix --- src/controllers/ProfileController.ts | 55 +++++++++++++++++--- src/controllers/ProfileEmployeeController.ts | 51 ++++++++++++++++-- 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/src/controllers/ProfileController.ts b/src/controllers/ProfileController.ts index 05ab62f8..254f5c46 100644 --- a/src/controllers/ProfileController.ts +++ b/src/controllers/ProfileController.ts @@ -11451,11 +11451,10 @@ export class ProfileController extends Controller { system?: string; }, ) { - // comment ออกก่อนเพราะยังไม่ได้ใช้ - // // ค้นหารายชื่อถ้าไม่ส่ง system มาให้ default ตามทะเบียนประวัติ - // let _system: string = "SYS_REGISTRY_OFFICER"; - // if (body.system) _system = body.system; - // let _data = await new permission().PermissionOrgList(request, _system); + // ค้นหารายชื่อถ้าไม่ส่ง system มาให้ default ตามทะเบียนประวัติ + let _system: string = "SYS_REGISTRY_OFFICER"; + if (body.system) _system = body.system; + let _data = await new permission().PermissionOrgList(request, _system); const findRevision = await this.orgRevisionRepo.findOne({ where: { orgRevisionIsCurrent: true }, }); @@ -11500,10 +11499,50 @@ export class ProfileController extends Controller { .leftJoinAndSelect("current_holders.orgChild2", "orgChild2") .leftJoinAndSelect("current_holders.orgChild3", "orgChild3") .leftJoinAndSelect("current_holders.orgChild4", "orgChild4") - .where(body.system ? "profile.isActive = :isActive" : "profile.isDelete = :isDelete", { - isActive: false, - isDelete: true, + .where("profile.isActive = :isActive AND profile.isDelete = :isDelete", { + isActive: true, + isDelete: false, }) + .andWhere( + _data.root != undefined && _data.root != null + ? _data.root[0] != null + ? `current_holders.orgRootId IN (:...root)` + : `current_holders.orgRootId is null` + : "1=1", + { root: _data.root }, + ) + .andWhere( + _data.child1 != undefined && _data.child1 != null + ? _data.child1[0] != null + ? `current_holders.orgChild1Id IN (:...child1)` + : `current_holders.orgChild1Id is null` + : "1=1", + { child1: _data.child1 }, + ) + .andWhere( + _data.child2 != undefined && _data.child2 != null + ? _data.child2[0] != null + ? `current_holders.orgChild2Id IN (:...child2)` + : `current_holders.orgChild2Id is null` + : "1=1", + { child2: _data.child2 }, + ) + .andWhere( + _data.child3 != undefined && _data.child3 != null + ? _data.child3[0] != null + ? `current_holders.orgChild3Id IN (:...child3)` + : `current_holders.orgChild3Id is null` + : "1=1", + { child3: _data.child3 }, + ) + .andWhere( + _data.child4 != undefined && _data.child4 != null + ? _data.child4[0] != null + ? `current_holders.orgChild4Id IN (:...child4)` + : `current_holders.orgChild4Id is null` + : "1=1", + { child4: _data.child4 }, + ) .andWhere( new Brackets((qb) => { qb.orWhere(body.keyword ? queryLike : "1=1", { keyword: `%${body.keyword}%` }); diff --git a/src/controllers/ProfileEmployeeController.ts b/src/controllers/ProfileEmployeeController.ts index 21eb9a5d..3dd769ed 100644 --- a/src/controllers/ProfileEmployeeController.ts +++ b/src/controllers/ProfileEmployeeController.ts @@ -6172,6 +6172,7 @@ export class ProfileEmployeeController extends Controller { */ @Post("search-personal-no-keycloak") async getProfileBySearchKeywordNoKeyCloak( + @Request() request: RequestWithUser, @Query("page") page: number = 1, @Query("pageSize") pageSize: number = 10, @Body() @@ -6181,6 +6182,10 @@ export class ProfileEmployeeController extends Controller { system?: string; }, ) { + // ค้นหารายชื่อถ้าไม่ส่ง system มาให้ default ตามทะเบียนประวัติ + let _system: string = "SYS_REGISTRY_EMP"; + if (body.system) _system = body.system; + let _data = await new permission().PermissionOrgList(request, _system); const findRevision = await this.orgRevisionRepo.findOne({ where: { orgRevisionIsCurrent: true }, }); @@ -6225,10 +6230,50 @@ export class ProfileEmployeeController extends Controller { .leftJoinAndSelect("current_holders.orgChild2", "orgChild2") .leftJoinAndSelect("current_holders.orgChild3", "orgChild3") .leftJoinAndSelect("current_holders.orgChild4", "orgChild4") - .where(body.system ? "profile.isActive = :isActive" : "profile.isDelete = :isDelete", { - isActive: false, - isDelete: true, + .where("profile.isActive = :isActive AND profile.isDelete = :isDelete", { + isActive: true, + isDelete: false, }) + .andWhere( + _data.root != undefined && _data.root != null + ? _data.root[0] != null + ? `current_holders.orgRootId IN (:...root)` + : `current_holders.orgRootId is null` + : "1=1", + { root: _data.root }, + ) + .andWhere( + _data.child1 != undefined && _data.child1 != null + ? _data.child1[0] != null + ? `current_holders.orgChild1Id IN (:...child1)` + : `current_holders.orgChild1Id is null` + : "1=1", + { child1: _data.child1 }, + ) + .andWhere( + _data.child2 != undefined && _data.child2 != null + ? _data.child2[0] != null + ? `current_holders.orgChild2Id IN (:...child2)` + : `current_holders.orgChild2Id is null` + : "1=1", + { child2: _data.child2 }, + ) + .andWhere( + _data.child3 != undefined && _data.child3 != null + ? _data.child3[0] != null + ? `current_holders.orgChild3Id IN (:...child3)` + : `current_holders.orgChild3Id is null` + : "1=1", + { child3: _data.child3 }, + ) + .andWhere( + _data.child4 != undefined && _data.child4 != null + ? _data.child4[0] != null + ? `current_holders.orgChild4Id IN (:...child4)` + : `current_holders.orgChild4Id is null` + : "1=1", + { child4: _data.child4 }, + ) .andWhere( new Brackets((qb) => { qb.orWhere(body.keyword ? queryLike : "1=1", { keyword: `%${body.keyword}%` });