diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 79befb8f..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,134 +0,0 @@ -# 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/`). diff --git a/src/controllers/ExRetirementController.ts b/src/controllers/ExRetirementController.ts index c9c45dae..6720f19d 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"; +const clientSecret = "5EhOvN5DwHOKakupqT9FmCk7MOwpT3zLqLPkPh4ZhJpxBN2nMG@2022"; class TokenCache { private static cache: Map = new Map(); @@ -43,35 +43,6 @@ 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") @@ -89,7 +60,7 @@ export class ExRetirementController extends Controller { }, ) { let retryCount = 0; - const maxRetries = EXPROFILE_MAX_RETRIES; + const maxRetries = 2; while (retryCount < maxRetries) { try { @@ -121,14 +92,11 @@ export class ExRetirementController extends Controller { // return res.data; return new HttpSuccess(res.data.data); } catch (error: any) { - if (isTransientError(error) && retryCount < maxRetries - 1) { + if (error.response?.status === 500 && retryCount < maxRetries - 1) { TokenCache.delete(`${clientId}:${clientSecret}`); - await sleep(EXPROFILE_BACKOFF_BASE_MS * 2 ** retryCount); retryCount++; continue; } - // log error message - console.error('getData getOfficerRetireData error:', error); throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "ไม่สามารถติดต่อ API ได้"); } } @@ -137,7 +105,7 @@ export class ExRetirementController extends Controller { @Get("/document/{documentId}") async getDocument(@Path("documentId") officerDocumentID: string, @Request() req: any) { let retryCount = 0; - const maxRetries = EXPROFILE_MAX_RETRIES; + const maxRetries = 2; while (retryCount < maxRetries) { try { const token = await getToken(clientId, clientSecret); @@ -166,13 +134,11 @@ export class ExRetirementController extends Controller { return; } } catch (error: any) { - if (isTransientError(error) && retryCount < maxRetries - 1) { + if (error.response?.status === 500 && retryCount < maxRetries - 1) { TokenCache.delete(`${clientId}:${clientSecret}`); - await sleep(EXPROFILE_BACKOFF_BASE_MS * 2 ** retryCount); retryCount++; continue; } - console.error('getData document error:', error); throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "ไม่สามารถติดต่อ API ได้"); } } @@ -202,13 +168,7 @@ async function getToken(ClientID: string, ClientSecret: string): Promise TokenCache.set(cacheKey, token); return token; } catch (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; + return Promise.reject({ message: "Error occurred", error }); } } @@ -234,7 +194,7 @@ export async function PostRetireToExprofile( } let retryCount = 0; - const maxRetries = EXPROFILE_MAX_RETRIES; + const maxRetries = 2; while (retryCount < maxRetries) { try { @@ -271,9 +231,8 @@ export async function PostRetireToExprofile( return res.data; } catch (error: any) { - if (isTransientError(error) && retryCount < maxRetries - 1) { + if (error.response?.status === 500 && retryCount < maxRetries - 1) { TokenCache.delete(`${clientId}:${clientSecret}`); - await sleep(EXPROFILE_BACKOFF_BASE_MS * 2 ** retryCount); retryCount++; continue; } @@ -292,7 +251,6 @@ export async function PostRetireToExprofile( }); } - console.error('importOfficerRetireData error', error); throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "ไม่สามารถติดต่อ API ได้"); } } diff --git a/src/controllers/ProfileController.ts b/src/controllers/ProfileController.ts index 254f5c46..05ab62f8 100644 --- a/src/controllers/ProfileController.ts +++ b/src/controllers/ProfileController.ts @@ -11451,10 +11451,11 @@ export class ProfileController extends Controller { system?: string; }, ) { - // ค้นหารายชื่อถ้าไม่ส่ง system มาให้ default ตามทะเบียนประวัติ - let _system: string = "SYS_REGISTRY_OFFICER"; - if (body.system) _system = body.system; - let _data = await new permission().PermissionOrgList(request, _system); + // comment ออกก่อนเพราะยังไม่ได้ใช้ + // // ค้นหารายชื่อถ้าไม่ส่ง 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 }, }); @@ -11499,50 +11500,10 @@ export class ProfileController extends Controller { .leftJoinAndSelect("current_holders.orgChild2", "orgChild2") .leftJoinAndSelect("current_holders.orgChild3", "orgChild3") .leftJoinAndSelect("current_holders.orgChild4", "orgChild4") - .where("profile.isActive = :isActive AND profile.isDelete = :isDelete", { - isActive: true, - isDelete: false, + .where(body.system ? "profile.isActive = :isActive" : "profile.isDelete = :isDelete", { + isActive: false, + isDelete: true, }) - .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 3dd769ed..21eb9a5d 100644 --- a/src/controllers/ProfileEmployeeController.ts +++ b/src/controllers/ProfileEmployeeController.ts @@ -6172,7 +6172,6 @@ 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() @@ -6182,10 +6181,6 @@ 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 }, }); @@ -6230,50 +6225,10 @@ export class ProfileEmployeeController extends Controller { .leftJoinAndSelect("current_holders.orgChild2", "orgChild2") .leftJoinAndSelect("current_holders.orgChild3", "orgChild3") .leftJoinAndSelect("current_holders.orgChild4", "orgChild4") - .where("profile.isActive = :isActive AND profile.isDelete = :isDelete", { - isActive: true, - isDelete: false, + .where(body.system ? "profile.isActive = :isActive" : "profile.isDelete = :isDelete", { + isActive: false, + isDelete: true, }) - .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/UserController.ts b/src/controllers/UserController.ts index c4b37b33..b5add4be 100644 --- a/src/controllers/UserController.ts +++ b/src/controllers/UserController.ts @@ -73,6 +73,7 @@ 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}") @@ -386,16 +387,12 @@ export class KeycloakController extends Controller { ); if (!result) throw new Error("Failed. Cannot remove user's role."); - // 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}`); + if (profileId) { try { await this.posMasterRepository .createQueryBuilder() .update(PosMaster) - .set({ authRoleId: authRoleId }) + .set({ authRoleId: '' }) .where("current_holderId = :profileId", { profileId }) .execute(); } catch (error) {