Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22ea54aeb3 | |||
| d3e1707cc2 | |||
| d10b7e11f0 | |||
| c356a06899 | |||
| 26732420c2 | |||
| 4ef29ad4db | |||
| 880c249975 | |||
|
|
ac9dc93d1e | ||
|
|
cd057bf81d |
6 changed files with 513 additions and 151 deletions
134
CLAUDE.md
Normal file
134
CLAUDE.md
Normal file
|
|
@ -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/<name> # 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 <token>`. 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/`).
|
||||
|
|
@ -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<string, CachedToken> = new Map();
|
||||
|
|
@ -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<void> {
|
||||
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,11 +121,14 @@ 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;
|
||||
}
|
||||
// log error message
|
||||
console.error('getData getOfficerRetireData error:', error);
|
||||
throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "ไม่สามารถติดต่อ API ได้");
|
||||
}
|
||||
}
|
||||
|
|
@ -105,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);
|
||||
|
|
@ -134,11 +166,13 @@ 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;
|
||||
}
|
||||
console.error('getData document error:', error);
|
||||
throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "ไม่สามารถติดต่อ API ได้");
|
||||
}
|
||||
}
|
||||
|
|
@ -168,7 +202,13 @@ async function getToken(ClientID: string, ClientSecret: string): Promise<string>
|
|||
TokenCache.set(cacheKey, token);
|
||||
return token;
|
||||
} catch (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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +234,7 @@ export async function PostRetireToExprofile(
|
|||
}
|
||||
|
||||
let retryCount = 0;
|
||||
const maxRetries = 2;
|
||||
const maxRetries = EXPROFILE_MAX_RETRIES;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
|
|
@ -231,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;
|
||||
}
|
||||
|
|
@ -251,6 +292,7 @@ export async function PostRetireToExprofile(
|
|||
});
|
||||
}
|
||||
|
||||
console.error('importOfficerRetireData error', error);
|
||||
throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "ไม่สามารถติดต่อ API ได้");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -47,6 +49,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";
|
||||
|
|
@ -65,6 +72,7 @@ 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 roleKeycloakRepo = AppDataSource.getRepository(RoleKeycloak);
|
||||
|
||||
@Get("user/{id}")
|
||||
|
|
@ -352,6 +360,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 +369,11 @@ 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);
|
||||
} 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 +385,53 @@ export class KeycloakController extends Controller {
|
|||
list.filter((v) => roleId === v.id),
|
||||
);
|
||||
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}`);
|
||||
try {
|
||||
await this.posMasterRepository
|
||||
.createQueryBuilder()
|
||||
.update(PosMaster)
|
||||
.set({ authRoleId: 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 {
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
204
src/keycloak/user-operations.ts
Normal file
204
src/keycloak/user-operations.ts
Normal file
|
|
@ -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<string> {
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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:",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue