completed sync script update keycloak
This commit is contained in:
parent
a487b73c3b
commit
625885973e
13 changed files with 1867 additions and 466 deletions
44
src/app.ts
44
src/app.ts
|
|
@ -15,6 +15,7 @@ import { logMemoryStore } from "./utils/LogMemoryStore";
|
|||
import { orgStructureCache } from "./utils/OrgStructureCache";
|
||||
import { CommandController } from "./controllers/CommandController";
|
||||
import { ProfileSalaryController } from "./controllers/ProfileSalaryController";
|
||||
import { ScriptProfileOrgController } from "./controllers/ScriptProfileOrgController";
|
||||
import { DateSerializer } from "./interfaces/date-serializer";
|
||||
|
||||
import { initWebSocket } from "./services/webSocket";
|
||||
|
|
@ -52,19 +53,8 @@ async function main() {
|
|||
const APP_HOST = process.env.APP_HOST || "0.0.0.0";
|
||||
const APP_PORT = +(process.env.APP_PORT || 3000);
|
||||
|
||||
const cronTime = "0 0 3 * * *"; // ตั้งเวลาทุกวันเวลา 03:00:00
|
||||
// const cronTime = "*/10 * * * * *";
|
||||
cron.schedule(cronTime, async () => {
|
||||
try {
|
||||
const orgController = new OrganizationController();
|
||||
await orgController.cronjobRevision();
|
||||
} catch (error) {
|
||||
console.error("Error executing function from controller:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const cronTime_command = "0 0 2 * * *";
|
||||
// const cronTime_command = "*/10 * * * * *";
|
||||
// Cron job for executing command - every day at 00:30:00
|
||||
const cronTime_command = "0 30 0 * * *";
|
||||
cron.schedule(cronTime_command, async () => {
|
||||
try {
|
||||
const commandController = new CommandController();
|
||||
|
|
@ -74,7 +64,19 @@ async function main() {
|
|||
}
|
||||
});
|
||||
|
||||
const cronTime_Oct = "0 0 1 10 *";
|
||||
// Cron job for updating org revision - every day at 01:00:00
|
||||
const cronTime = "0 0 1 * * *";
|
||||
cron.schedule(cronTime, async () => {
|
||||
try {
|
||||
const orgController = new OrganizationController();
|
||||
await orgController.cronjobRevision();
|
||||
} catch (error) {
|
||||
console.error("Error executing function from controller:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// Cron job for updating retirement status - every day at 02:00:00 on the 1st of October
|
||||
const cronTime_Oct = "0 0 2 10 *";
|
||||
cron.schedule(cronTime_Oct, async () => {
|
||||
try {
|
||||
const commandController = new CommandController();
|
||||
|
|
@ -84,7 +86,19 @@ async function main() {
|
|||
}
|
||||
});
|
||||
|
||||
const cronTime_Tenure = "0 0 0 * * *";
|
||||
// Cron job for updating org DNA - every day at 03:00:00
|
||||
const cronTime_UpdateOrg = "0 0 3 * * *";
|
||||
cron.schedule(cronTime_UpdateOrg, async () => {
|
||||
try {
|
||||
const scriptProfileOrgController = new ScriptProfileOrgController();
|
||||
await scriptProfileOrgController.cronjobUpdateOrg({} as any);
|
||||
} catch (error) {
|
||||
console.error("Error executing cronjobUpdateOrg:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// Cron job for updating tenure - every day at 04:00:00
|
||||
const cronTime_Tenure = "0 0 4 * * *";
|
||||
cron.schedule(cronTime_Tenure, async () => {
|
||||
try {
|
||||
const profileSalaryController = new ProfileSalaryController();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import { Controller, Post, Get, Route, Security, Tags, Path, Request, Response, Query } from "tsoa";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Route,
|
||||
Security,
|
||||
Tags,
|
||||
Path,
|
||||
Request,
|
||||
Response,
|
||||
Query,
|
||||
Body,
|
||||
} from "tsoa";
|
||||
import { KeycloakAttributeService } from "../services/KeycloakAttributeService";
|
||||
import HttpSuccess from "../interfaces/http-success";
|
||||
import HttpStatus from "../interfaces/http-status";
|
||||
import HttpError from "../interfaces/http-error";
|
||||
import { RequestWithUser } from "../middlewares/user";
|
||||
import { AppDataSource } from "../database/data-source";
|
||||
import { Profile } from "../entities/Profile";
|
||||
import { ProfileEmployee } from "../entities/ProfileEmployee";
|
||||
|
||||
@Route("api/v1/org/keycloak-sync")
|
||||
@Tags("Keycloak Sync")
|
||||
|
|
@ -17,8 +26,6 @@ import { ProfileEmployee } from "../entities/ProfileEmployee";
|
|||
)
|
||||
export class KeycloakSyncController extends Controller {
|
||||
private keycloakAttributeService = new KeycloakAttributeService();
|
||||
private profileRepo = AppDataSource.getRepository(Profile);
|
||||
private profileEmployeeRepo = AppDataSource.getRepository(ProfileEmployee);
|
||||
|
||||
/**
|
||||
* Sync attributes for the current logged-in user
|
||||
|
|
@ -62,7 +69,7 @@ export class KeycloakSyncController extends Controller {
|
|||
*
|
||||
* @summary Get current profileId and rootDnaId from Keycloak
|
||||
*/
|
||||
@Get("my-attributes")
|
||||
// @Get("my-attributes")
|
||||
async getMyAttributes(@Request() request: RequestWithUser) {
|
||||
const keycloakUserId = request.user.sub;
|
||||
|
||||
|
|
@ -117,19 +124,80 @@ export class KeycloakSyncController extends Controller {
|
|||
}
|
||||
|
||||
/**
|
||||
* Batch sync all users (Admin only)
|
||||
* Batch sync attributes for multiple profiles (Admin only)
|
||||
*
|
||||
* @summary Batch sync all users to Keycloak (ADMIN)
|
||||
* @summary Batch sync profileId and rootDnaId to Keycloak for multiple profiles (ADMIN)
|
||||
*
|
||||
* @param {number} limit Maximum number of users to sync
|
||||
* @param {request} request Request body containing profileIds array and profileType
|
||||
*/
|
||||
@Post("sync-all")
|
||||
async syncAll(@Query() limit: number = 100) {
|
||||
if (limit > 500) {
|
||||
throw new HttpError(HttpStatus.BAD_REQUEST, "limit ต้องไม่เกิน 500");
|
||||
// @Post("sync-profiles-batch")
|
||||
async syncByProfileIds(
|
||||
@Body() request: { profileIds: string[]; profileType: "PROFILE" | "PROFILE_EMPLOYEE" },
|
||||
) {
|
||||
const { profileIds, profileType } = request;
|
||||
|
||||
// Validate profileIds
|
||||
if (!profileIds || profileIds.length === 0) {
|
||||
throw new HttpError(HttpStatus.BAD_REQUEST, "profileIds ต้องไม่ว่างเปล่า");
|
||||
}
|
||||
|
||||
const result = await this.keycloakAttributeService.batchSyncUsers(limit);
|
||||
// Validate profileType
|
||||
if (!["PROFILE", "PROFILE_EMPLOYEE"].includes(profileType)) {
|
||||
throw new HttpError(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"profileType ต้องเป็น PROFILE หรือ PROFILE_EMPLOYEE เท่านั้น",
|
||||
);
|
||||
}
|
||||
|
||||
const result = {
|
||||
total: profileIds.length,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
details: [] as Array<{ profileId: string; status: "success" | "failed"; error?: string }>,
|
||||
};
|
||||
|
||||
// Process each profileId
|
||||
for (const profileId of profileIds) {
|
||||
try {
|
||||
const success = await this.keycloakAttributeService.syncOnOrganizationChange(
|
||||
profileId,
|
||||
profileType,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
result.success++;
|
||||
result.details.push({ profileId, status: "success" });
|
||||
} else {
|
||||
result.failed++;
|
||||
result.details.push({
|
||||
profileId,
|
||||
status: "failed",
|
||||
error: "Sync returned false - ไม่พบข้อมูล profile หรือ Keycloak user ID",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.failed++;
|
||||
result.details.push({ profileId, status: "failed", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return new HttpSuccess({
|
||||
message: "Batch sync เสร็จสิ้น",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch sync all users (Admin only)
|
||||
*
|
||||
* @summary Batch sync all users to Keycloak without limit (ADMIN)
|
||||
*
|
||||
* @description Syncs profileId and orgRootDnaId to Keycloak for all users
|
||||
* that have a keycloak ID. Uses parallel processing for better performance.
|
||||
*/
|
||||
// @Post("sync-all")
|
||||
async syncAll() {
|
||||
const result = await this.keycloakAttributeService.batchSyncUsers();
|
||||
|
||||
return new HttpSuccess({
|
||||
message: "Batch sync เสร็จสิ้น",
|
||||
|
|
@ -141,58 +209,46 @@ export class KeycloakSyncController extends Controller {
|
|||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบสถานะ Keycloak Mapper
|
||||
* Ensure Keycloak users exist for all profiles (Admin only)
|
||||
*
|
||||
* @summary ตรวจสอบว่า profileId และ rootDnaId ออกมาใน token หรือไม่
|
||||
* @summary Create or verify Keycloak users for all profiles in Profile and ProfileEmployee tables (ADMIN)
|
||||
*
|
||||
* @description
|
||||
* This endpoint will:
|
||||
* - Create new Keycloak users for profiles without a keycloak ID
|
||||
* - Create new Keycloak users for profiles where the stored keycloak ID doesn't exist in Keycloak
|
||||
* - Verify existing Keycloak users
|
||||
* - Skip profiles without a citizenId
|
||||
*/
|
||||
@Get("check-mapper")
|
||||
async checkMapperStatus(@Request() request: RequestWithUser) {
|
||||
const keycloakUserId = request.user.sub;
|
||||
|
||||
if (!keycloakUserId) {
|
||||
throw new HttpError(HttpStatus.UNAUTHORIZED, "ไม่พบ Keycloak user ID");
|
||||
}
|
||||
|
||||
// 1. ตรวจสอบ attributes ใน Keycloak
|
||||
const kcAttrs =
|
||||
await this.keycloakAttributeService.getCurrentKeycloakAttributes(keycloakUserId);
|
||||
|
||||
// 2. ตรวจสอบ attributes ใน Database
|
||||
const dbAttrs = await this.keycloakAttributeService.getUserProfileAttributes(keycloakUserId);
|
||||
|
||||
// 3. ตรวจสอบ token payload ปัจจุบัน
|
||||
const tokenPayload = request.user;
|
||||
|
||||
// @Post("ensure-users")
|
||||
async ensureAllUsers() {
|
||||
const result = await this.keycloakAttributeService.batchEnsureKeycloakUsers();
|
||||
return new HttpSuccess({
|
||||
keycloakAttributes: kcAttrs,
|
||||
databaseAttributes: dbAttrs,
|
||||
tokenHasProfileId: !!tokenPayload.profileId,
|
||||
tokenHasOrgRootDnaId: !!tokenPayload.orgRootDnaId,
|
||||
tokenScopes: tokenPayload.scope?.split(" ") || [],
|
||||
diagnosis: {
|
||||
kcHasProfileId: !!kcAttrs?.profileId,
|
||||
kcHasOrgRootDnaId: !!kcAttrs?.orgRootDnaId,
|
||||
kcHasOrgChild1DnaId: !!kcAttrs?.orgChild1DnaId,
|
||||
kcHasOrgChild2DnaId: !!kcAttrs?.orgChild2DnaId,
|
||||
kcHasOrgChild3DnaId: !!kcAttrs?.orgChild3DnaId,
|
||||
kcHasOrgChild4DnaId: !!kcAttrs?.orgChild4DnaId,
|
||||
kcHasEmpType: !!kcAttrs?.empType,
|
||||
dbHasProfileId: !!dbAttrs?.profileId,
|
||||
dbHasOrgRootDnaId: !!dbAttrs?.orgRootDnaId,
|
||||
dbHasOrgChild1DnaId: !!dbAttrs?.orgChild1DnaId,
|
||||
dbHasOrgChild2DnaId: !!dbAttrs?.orgChild2DnaId,
|
||||
dbHasOrgChild3DnaId: !!dbAttrs?.orgChild3DnaId,
|
||||
dbHasOrgChild4DnaId: !!dbAttrs?.orgChild4DnaId,
|
||||
dbHasEmpType: !!dbAttrs?.empType,
|
||||
issue:
|
||||
!tokenPayload.profileId && kcAttrs?.profileId
|
||||
? "Attribute มีใน Keycloak แต่ไม่ออกมาใน token - แก้ไข Mapper หรือ Client Scope"
|
||||
: !kcAttrs?.profileId && dbAttrs?.profileId
|
||||
? "Attribute มีใน Database แต่ไม่มีใน Keycloak - ต้อง sync ซ้ำ"
|
||||
: !dbAttrs?.profileId
|
||||
? "ไม่พบ profile ใน database - ตรวจสอบ keycloak field"
|
||||
: "ทุกอย่างปกติ",
|
||||
},
|
||||
message: "Batch ensure Keycloak users เสร็จสิ้น",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear orphaned Keycloak users (Admin only)
|
||||
*
|
||||
* @summary Delete Keycloak users that are not in the database (ADMIN)
|
||||
*
|
||||
* @description
|
||||
* This endpoint will:
|
||||
* - Find users in Keycloak that are not referenced in Profile or ProfileEmployee tables
|
||||
* - Delete those orphaned users from Keycloak
|
||||
* - Skip protected users (super_admin, admin_issue)
|
||||
*
|
||||
* @param {request} request Request body containing skipUsernames array
|
||||
*/
|
||||
// @Post("clear-orphaned-users")
|
||||
async clearOrphanedUsers(@Body() request?: { skipUsernames?: string[] }) {
|
||||
const skipUsernames = request?.skipUsernames || ["super_admin", "admin_issue"];
|
||||
const result = await this.keycloakAttributeService.clearOrphanedKeycloakUsers(skipUsernames);
|
||||
return new HttpSuccess({
|
||||
message: "Clear orphaned Keycloak users เสร็จสิ้น",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
326
src/controllers/ScriptProfileOrgController.ts
Normal file
326
src/controllers/ScriptProfileOrgController.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import { Controller, Post, Route, Security, Tags, Request } from "tsoa";
|
||||
import { AppDataSource } from "../database/data-source";
|
||||
import HttpSuccess from "../interfaces/http-success";
|
||||
import HttpStatus from "../interfaces/http-status";
|
||||
import HttpError from "../interfaces/http-error";
|
||||
import { RequestWithUser } from "../middlewares/user";
|
||||
import { MoreThanOrEqual } from "typeorm";
|
||||
import { PosMaster } from "./../entities/PosMaster";
|
||||
import axios from "axios";
|
||||
import { KeycloakSyncController } from "./KeycloakSyncController";
|
||||
import { EmployeePosMaster } from "./../entities/EmployeePosMaster";
|
||||
|
||||
interface OrgUpdatePayload {
|
||||
profileId: string;
|
||||
rootDnaId: string | null;
|
||||
child1DnaId: string | null;
|
||||
child2DnaId: string | null;
|
||||
child3DnaId: string | null;
|
||||
child4DnaId: string | null;
|
||||
profileType: "PROFILE" | "PROFILE_EMPLOYEE";
|
||||
}
|
||||
|
||||
@Route("api/v1/org/script-profile-org")
|
||||
@Tags("Keycloak Sync")
|
||||
@Security("bearerAuth")
|
||||
export class ScriptProfileOrgController extends Controller {
|
||||
private posMasterRepo = AppDataSource.getRepository(PosMaster);
|
||||
private employeePosMasterRepo = AppDataSource.getRepository(EmployeePosMaster);
|
||||
|
||||
// Idempotency flag to prevent concurrent runs
|
||||
private isRunning = false;
|
||||
|
||||
// Configurable values
|
||||
private readonly BATCH_SIZE = parseInt(process.env.CRONJOB_BATCH_SIZE || "100", 10);
|
||||
private readonly UPDATE_WINDOW_HOURS = parseInt(
|
||||
process.env.CRONJOB_UPDATE_WINDOW_HOURS || "24",
|
||||
10,
|
||||
);
|
||||
|
||||
@Post("update-org")
|
||||
public async cronjobUpdateOrg(@Request() request: RequestWithUser) {
|
||||
// Idempotency check - prevent concurrent runs
|
||||
if (this.isRunning) {
|
||||
console.log("cronjobUpdateOrg: Job already running, skipping this execution");
|
||||
return new HttpSuccess({
|
||||
message: "Job already running",
|
||||
skipped: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const windowStart = new Date(Date.now() - this.UPDATE_WINDOW_HOURS * 60 * 60 * 1000);
|
||||
|
||||
console.log("cronjobUpdateOrg: Starting job", {
|
||||
windowHours: this.UPDATE_WINDOW_HOURS,
|
||||
windowStart: windowStart.toISOString(),
|
||||
batchSize: this.BATCH_SIZE,
|
||||
});
|
||||
|
||||
// Query with optimized select - only fetch required fields
|
||||
const [posMasters, posMasterEmployee] = await Promise.all([
|
||||
this.posMasterRepo.find({
|
||||
where: {
|
||||
lastUpdatedAt: MoreThanOrEqual(windowStart),
|
||||
orgRevision: {
|
||||
orgRevisionIsCurrent: true,
|
||||
},
|
||||
},
|
||||
relations: [
|
||||
"orgRevision",
|
||||
"orgRoot",
|
||||
"orgChild1",
|
||||
"orgChild2",
|
||||
"orgChild3",
|
||||
"orgChild4",
|
||||
"current_holder",
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
current_holderId: true,
|
||||
lastUpdatedAt: true,
|
||||
orgRevision: { id: true },
|
||||
orgRoot: { ancestorDNA: true },
|
||||
orgChild1: { ancestorDNA: true },
|
||||
orgChild2: { ancestorDNA: true },
|
||||
orgChild3: { ancestorDNA: true },
|
||||
orgChild4: { ancestorDNA: true },
|
||||
current_holder: { id: true },
|
||||
},
|
||||
}),
|
||||
this.employeePosMasterRepo.find({
|
||||
where: {
|
||||
lastUpdatedAt: MoreThanOrEqual(windowStart),
|
||||
orgRevision: {
|
||||
orgRevisionIsCurrent: true,
|
||||
},
|
||||
},
|
||||
relations: [
|
||||
"orgRevision",
|
||||
"orgRoot",
|
||||
"orgChild1",
|
||||
"orgChild2",
|
||||
"orgChild3",
|
||||
"orgChild4",
|
||||
"current_holder",
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
current_holderId: true,
|
||||
lastUpdatedAt: true,
|
||||
orgRevision: { id: true },
|
||||
orgRoot: { ancestorDNA: true },
|
||||
orgChild1: { ancestorDNA: true },
|
||||
orgChild2: { ancestorDNA: true },
|
||||
orgChild3: { ancestorDNA: true },
|
||||
orgChild4: { ancestorDNA: true },
|
||||
current_holder: { id: true },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log("cronjobUpdateOrg: Database query completed", {
|
||||
posMastersCount: posMasters.length,
|
||||
employeePosCount: posMasterEmployee.length,
|
||||
totalRecords: posMasters.length + posMasterEmployee.length,
|
||||
});
|
||||
|
||||
// Build payloads with proper profile type tracking
|
||||
const payloads = this.buildPayloads(posMasters, posMasterEmployee);
|
||||
|
||||
if (payloads.length === 0) {
|
||||
console.log("cronjobUpdateOrg: No records to process");
|
||||
return new HttpSuccess({
|
||||
message: "No records to process",
|
||||
processed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Update profile's org structure in leave service by calling API
|
||||
console.log("cronjobUpdateOrg: Calling leave service API", {
|
||||
payloadCount: payloads.length,
|
||||
});
|
||||
|
||||
await axios.put(`${process.env.API_URL}/leave-beginning/schedule/update-dna`, payloads, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
api_key: process.env.API_KEY,
|
||||
},
|
||||
timeout: 30000, // 30 second timeout
|
||||
});
|
||||
|
||||
console.log("cronjobUpdateOrg: Leave service API call successful");
|
||||
|
||||
// Group profile IDs by type for proper syncing
|
||||
const profileIdsByType = this.groupProfileIdsByType(payloads);
|
||||
|
||||
// Sync to Keycloak with batching
|
||||
const keycloakSyncController = new KeycloakSyncController();
|
||||
const syncResults = {
|
||||
total: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
byType: {} as Record<string, { total: number; success: number; failed: number }>,
|
||||
};
|
||||
|
||||
// Process each profile type separately
|
||||
for (const [profileType, profileIds] of Object.entries(profileIdsByType)) {
|
||||
console.log(`cronjobUpdateOrg: Syncing ${profileType} profiles`, {
|
||||
count: profileIds.length,
|
||||
});
|
||||
|
||||
const batches = this.chunkArray(profileIds, this.BATCH_SIZE);
|
||||
const typeResult = { total: profileIds.length, success: 0, failed: 0 };
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
console.log(
|
||||
`cronjobUpdateOrg: Processing batch ${i + 1}/${batches.length} for ${profileType}`,
|
||||
{
|
||||
batchSize: batch.length,
|
||||
batchRange: `${i * this.BATCH_SIZE + 1}-${Math.min(
|
||||
(i + 1) * this.BATCH_SIZE,
|
||||
profileIds.length,
|
||||
)}`,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const batchResult: any = await keycloakSyncController.syncByProfileIds({
|
||||
profileIds: batch,
|
||||
profileType: profileType as "PROFILE" | "PROFILE_EMPLOYEE",
|
||||
});
|
||||
|
||||
// Extract result data if available
|
||||
const resultData = (batchResult as any)?.data || batchResult;
|
||||
typeResult.success += resultData.success || 0;
|
||||
typeResult.failed += resultData.failed || 0;
|
||||
|
||||
console.log(`cronjobUpdateOrg: Batch ${i + 1}/${batches.length} completed`, {
|
||||
success: resultData.success || 0,
|
||||
failed: resultData.failed || 0,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`cronjobUpdateOrg: Batch ${i + 1}/${batches.length} failed`, {
|
||||
error: error.message,
|
||||
batchSize: batch.length,
|
||||
});
|
||||
// Count all profiles in failed batch as failed
|
||||
typeResult.failed += batch.length;
|
||||
}
|
||||
}
|
||||
|
||||
syncResults.byType[profileType] = typeResult;
|
||||
syncResults.total += typeResult.total;
|
||||
syncResults.success += typeResult.success;
|
||||
syncResults.failed += typeResult.failed;
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log("cronjobUpdateOrg: Job completed", {
|
||||
duration: `${duration}ms`,
|
||||
processed: payloads.length,
|
||||
syncResults,
|
||||
});
|
||||
|
||||
return new HttpSuccess({
|
||||
message: "Update org completed",
|
||||
processed: payloads.length,
|
||||
syncResults,
|
||||
duration: `${duration}ms`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error("cronjobUpdateOrg: Job failed", {
|
||||
duration: `${duration}ms`,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw new HttpError(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error");
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build payloads from PosMaster and EmployeePosMaster records
|
||||
* Includes proper profile type tracking for accurate Keycloak sync
|
||||
*/
|
||||
private buildPayloads(
|
||||
posMasters: PosMaster[],
|
||||
posMasterEmployee: EmployeePosMaster[],
|
||||
): OrgUpdatePayload[] {
|
||||
const payloads: OrgUpdatePayload[] = [];
|
||||
|
||||
// Process PosMaster records (PROFILE type)
|
||||
for (const posMaster of posMasters) {
|
||||
if (posMaster.current_holder && posMaster.current_holderId) {
|
||||
payloads.push({
|
||||
profileId: posMaster.current_holderId,
|
||||
rootDnaId: posMaster.orgRoot?.ancestorDNA || null,
|
||||
child1DnaId: posMaster.orgChild1?.ancestorDNA || null,
|
||||
child2DnaId: posMaster.orgChild2?.ancestorDNA || null,
|
||||
child3DnaId: posMaster.orgChild3?.ancestorDNA || null,
|
||||
child4DnaId: posMaster.orgChild4?.ancestorDNA || null,
|
||||
profileType: "PROFILE",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process EmployeePosMaster records (PROFILE_EMPLOYEE type)
|
||||
for (const employeePos of posMasterEmployee) {
|
||||
if (employeePos.current_holder && employeePos.current_holderId) {
|
||||
payloads.push({
|
||||
profileId: employeePos.current_holderId,
|
||||
rootDnaId: employeePos.orgRoot?.ancestorDNA || null,
|
||||
child1DnaId: employeePos.orgChild1?.ancestorDNA || null,
|
||||
child2DnaId: employeePos.orgChild2?.ancestorDNA || null,
|
||||
child3DnaId: employeePos.orgChild3?.ancestorDNA || null,
|
||||
child4DnaId: employeePos.orgChild4?.ancestorDNA || null,
|
||||
profileType: "PROFILE_EMPLOYEE",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return payloads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group profile IDs by their type for separate Keycloak sync calls
|
||||
*/
|
||||
private groupProfileIdsByType(payloads: OrgUpdatePayload[]): Record<string, string[]> {
|
||||
const grouped: Record<string, string[]> = {
|
||||
PROFILE: [],
|
||||
PROFILE_EMPLOYEE: [],
|
||||
};
|
||||
|
||||
for (const payload of payloads) {
|
||||
grouped[payload.profileType].push(payload.profileId);
|
||||
}
|
||||
|
||||
// Remove empty groups and deduplicate IDs within each group
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [type, ids] of Object.entries(grouped)) {
|
||||
if (ids.length > 0) {
|
||||
// Deduplicate while preserving order
|
||||
result[type] = Array.from(new Set(ids));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split array into chunks of specified size
|
||||
*/
|
||||
private chunkArray<T>(array: T[], chunkSize: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += chunkSize) {
|
||||
chunks.push(array.slice(i, i + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@ const KC_URL = process.env.KC_URL;
|
|||
const KC_REALMS = process.env.KC_REALMS;
|
||||
const KC_CLIENT_ID = process.env.KC_SERVICE_ACCOUNT_CLIENT_ID;
|
||||
const KC_SECRET = process.env.KC_SERVICE_ACCOUNT_SECRET;
|
||||
const AUTH_ACCOUNT_SECRET = process.env.AUTH_ACCOUNT_SECRET;
|
||||
const API_KEY = process.env.API_KEY;
|
||||
// const AUTH_ACCOUNT_SECRET = process.env.AUTH_ACCOUNT_SECRET;
|
||||
// const API_KEY = process.env.API_KEY;
|
||||
|
||||
let token: string | null = null;
|
||||
let decoded: DecodedJwt | null = null;
|
||||
|
|
@ -165,16 +165,119 @@ export async function getUserList(first = "", max = "", search = "") {
|
|||
|
||||
if (!res) return false;
|
||||
if (!res.ok) {
|
||||
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
|
||||
const errorText = await res.text();
|
||||
return Boolean(console.error("Keycloak Error Response: ", errorText));
|
||||
}
|
||||
|
||||
return ((await res.json()) as any[]).map((v: Record<string, string>) => ({
|
||||
// Get raw text first to handle potential JSON parsing errors
|
||||
const rawText = await res.text();
|
||||
|
||||
// Log response size for debugging
|
||||
console.log(`[getUserList] Response size: ${rawText.length} bytes`);
|
||||
|
||||
try {
|
||||
const data = JSON.parse(rawText) as any[];
|
||||
return data.map((v: Record<string, string>) => ({
|
||||
id: v.id,
|
||||
username: v.username,
|
||||
firstName: v.firstName,
|
||||
lastName: v.lastName,
|
||||
email: v.email,
|
||||
enabled: v.enabled,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[getUserList] Failed to parse JSON response:`);
|
||||
console.error(`[getUserList] Response preview (first 500 chars):`, rawText.substring(0, 500));
|
||||
console.error(`[getUserList] Response preview (last 200 chars):`, rawText.slice(-200));
|
||||
throw new Error(
|
||||
`Failed to parse Keycloak response as JSON. Response may be truncated or malformed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keycloak users with pagination to avoid response size limits
|
||||
*
|
||||
* Client must have permission to manage realm's user
|
||||
*
|
||||
* @returns user list if success, false otherwise.
|
||||
*/
|
||||
export async function getAllUsersPaginated(
|
||||
search: string = "",
|
||||
batchSize: number = 100,
|
||||
): Promise<
|
||||
| Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
enabled: boolean;
|
||||
}>
|
||||
| false
|
||||
> {
|
||||
const allUsers: any[] = [];
|
||||
let first = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const res = await fetch(
|
||||
`${KC_URL}/admin/realms/${KC_REALMS}/users?first=${first}&max=${batchSize}${search ? `&search=${search}` : ""}`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${await getToken()}`,
|
||||
"content-type": `application/json`,
|
||||
},
|
||||
},
|
||||
).catch((e) => console.log("Keycloak Error: ", e));
|
||||
|
||||
if (!res) return false;
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
console.error("Keycloak Error Response: ", errorText);
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawText = await res.text();
|
||||
|
||||
try {
|
||||
const batch = JSON.parse(rawText) as any[];
|
||||
|
||||
if (batch.length === 0) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
allUsers.push(...batch);
|
||||
first += batch.length;
|
||||
hasMore = batch.length === batchSize;
|
||||
|
||||
// Log progress for large datasets
|
||||
if (allUsers.length % 500 === 0) {
|
||||
console.log(`[getAllUsersPaginated] Fetched ${allUsers.length} users so far...`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[getAllUsersPaginated] Failed to parse JSON response at offset ${first}:`);
|
||||
console.error(
|
||||
`[getAllUsersPaginated] Response preview (first 500 chars):`,
|
||||
rawText.substring(0, 500),
|
||||
);
|
||||
console.error(
|
||||
`[getAllUsersPaginated] Response preview (last 200 chars):`,
|
||||
rawText.slice(-200),
|
||||
);
|
||||
throw new Error(`Failed to parse Keycloak response as JSON at batch starting at ${first}.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[getAllUsersPaginated] Total users fetched: ${allUsers.length}`);
|
||||
|
||||
return allUsers.map((v: any) => ({
|
||||
id: v.id,
|
||||
username: v.username,
|
||||
firstName: v.firstName,
|
||||
lastName: v.lastName,
|
||||
email: v.email,
|
||||
enabled: v.enabled,
|
||||
enabled: v.enabled === true || v.enabled === "true",
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -220,17 +323,34 @@ export async function getUserListOrg(first = "", max = "", search = "", userIds:
|
|||
|
||||
if (!res) return false;
|
||||
if (!res.ok) {
|
||||
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
|
||||
const errorText = await res.text();
|
||||
return Boolean(console.error("Keycloak Error Response: ", errorText));
|
||||
}
|
||||
|
||||
return ((await res.json()) as any[]).map((v: Record<string, string>) => ({
|
||||
id: v.id,
|
||||
username: v.username,
|
||||
firstName: v.firstName,
|
||||
lastName: v.lastName,
|
||||
email: v.email,
|
||||
enabled: v.enabled,
|
||||
}));
|
||||
// Get raw text first to handle potential JSON parsing errors
|
||||
const rawText = await res.text();
|
||||
|
||||
try {
|
||||
const data = JSON.parse(rawText) as any[];
|
||||
return data.map((v: Record<string, string>) => ({
|
||||
id: v.id,
|
||||
username: v.username,
|
||||
firstName: v.firstName,
|
||||
lastName: v.lastName,
|
||||
email: v.email,
|
||||
enabled: v.enabled,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[getUserListOrg] Failed to parse JSON response:`);
|
||||
console.error(
|
||||
`[getUserListOrg] Response preview (first 500 chars):`,
|
||||
rawText.substring(0, 500),
|
||||
);
|
||||
console.error(`[getUserListOrg] Response preview (last 200 chars):`, rawText.slice(-200));
|
||||
throw new Error(
|
||||
`Failed to parse Keycloak response as JSON. Response may be truncated or malformed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserCountOrg(first = "", max = "", search = "", userIds: string[] = []) {
|
||||
|
|
@ -444,10 +564,12 @@ export async function getRoles(name?: string, token?: string) {
|
|||
}));
|
||||
}
|
||||
|
||||
// return {
|
||||
// id: data.id,
|
||||
// name: data.name,
|
||||
// };
|
||||
// Return single role object
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -793,17 +915,20 @@ export async function updateUserAttributes(
|
|||
}
|
||||
|
||||
// Merge existing attributes with new attributes
|
||||
// Keycloak requires id to be present in the payload
|
||||
// IMPORTANT: Spread all existing user fields to preserve firstName, lastName, email, etc.
|
||||
// The Keycloak PUT endpoint performs a full update, so we must include all fields
|
||||
const updatedAttributes = {
|
||||
id: existingUser.id,
|
||||
enabled: existingUser.enabled ?? true,
|
||||
...existingUser,
|
||||
attributes: {
|
||||
...(existingUser.attributes || {}),
|
||||
...attributes,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(`[updateUserAttributes] Sending to Keycloak:`, JSON.stringify(updatedAttributes, null, 2));
|
||||
console.log(
|
||||
`[updateUserAttributes] Sending to Keycloak:`,
|
||||
JSON.stringify(updatedAttributes, null, 2),
|
||||
);
|
||||
|
||||
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}`, {
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export async function expressAuthentication(
|
|||
request.app.locals.logData.orgChild3DnaId = payload.orgChild3DnaId ?? "";
|
||||
request.app.locals.logData.orgChild4DnaId = payload.orgChild4DnaId ?? "";
|
||||
request.app.locals.logData.empType = payload.empType ?? "";
|
||||
request.app.locals.logData.prefix = payload.prefix ?? "";
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type RequestWithUser = Request & {
|
|||
orgChild3DnaId?: string;
|
||||
orgChild4DnaId?: string;
|
||||
empType?: string;
|
||||
prefix?: string;
|
||||
scope?: string;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,16 @@ import { ProfileEmployee } from "../entities/ProfileEmployee";
|
|||
// import { PosMaster } from "../entities/PosMaster";
|
||||
// import { EmployeePosMaster } from "../entities/EmployeePosMaster";
|
||||
// import { OrgRoot } from "../entities/OrgRoot";
|
||||
import { getUser, updateUserAttributes } from "../keycloak";
|
||||
import {
|
||||
createUser,
|
||||
getUser,
|
||||
getUserByUsername,
|
||||
updateUserAttributes,
|
||||
deleteUser,
|
||||
getRoles,
|
||||
addUserRoles,
|
||||
getAllUsersPaginated,
|
||||
} from "../keycloak";
|
||||
import { OrgRevision } from "../entities/OrgRevision";
|
||||
|
||||
export interface UserProfileAttributes {
|
||||
|
|
@ -15,6 +24,7 @@ export interface UserProfileAttributes {
|
|||
orgChild3DnaId: string | null;
|
||||
orgChild4DnaId: string | null;
|
||||
empType: string | null;
|
||||
prefix?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -73,6 +83,7 @@ export class KeycloakAttributeService {
|
|||
orgChild3DnaId,
|
||||
orgChild4DnaId,
|
||||
empType: "OFFICER",
|
||||
prefix: profileResult.prefix,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -107,6 +118,7 @@ export class KeycloakAttributeService {
|
|||
orgChild3DnaId,
|
||||
orgChild4DnaId,
|
||||
empType: profileEmployeeResult.employeeClass,
|
||||
prefix: profileEmployeeResult.prefix,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +131,7 @@ export class KeycloakAttributeService {
|
|||
orgChild3DnaId: null,
|
||||
orgChild4DnaId: null,
|
||||
empType: null,
|
||||
prefix: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +183,7 @@ export class KeycloakAttributeService {
|
|||
orgChild3DnaId,
|
||||
orgChild4DnaId,
|
||||
empType: "OFFICER",
|
||||
prefix: profileResult.prefix,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
|
@ -204,6 +218,7 @@ export class KeycloakAttributeService {
|
|||
orgChild3DnaId,
|
||||
orgChild4DnaId,
|
||||
empType: profileEmployeeResult.employeeClass,
|
||||
prefix: profileEmployeeResult.prefix,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -216,6 +231,7 @@ export class KeycloakAttributeService {
|
|||
orgChild3DnaId: null,
|
||||
orgChild4DnaId: null,
|
||||
empType: null,
|
||||
prefix: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -243,6 +259,7 @@ export class KeycloakAttributeService {
|
|||
orgChild3DnaId: [attributes.orgChild3DnaId || ""],
|
||||
orgChild4DnaId: [attributes.orgChild4DnaId || ""],
|
||||
empType: [attributes.empType || ""],
|
||||
prefix: [attributes.prefix || ""],
|
||||
};
|
||||
|
||||
const success = await updateUserAttributes(keycloakUserId, keycloakAttributes);
|
||||
|
|
@ -297,15 +314,19 @@ export class KeycloakAttributeService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Batch sync multiple users
|
||||
* Batch sync multiple users with unlimited count and parallel processing
|
||||
* Useful for initial sync or periodic updates
|
||||
*
|
||||
* @param limit - Maximum number of users to sync (default: 100)
|
||||
* @param options - Optional configuration (limit for testing, concurrency for parallel processing)
|
||||
* @returns Object with success count and details
|
||||
*/
|
||||
async batchSyncUsers(
|
||||
limit: number = 100,
|
||||
): Promise<{ total: number; success: number; failed: number; details: any[] }> {
|
||||
async batchSyncUsers(options?: {
|
||||
limit?: number;
|
||||
concurrency?: number;
|
||||
}): Promise<{ total: number; success: number; failed: number; details: any[] }> {
|
||||
const limit = options?.limit;
|
||||
const concurrency = options?.concurrency ?? 5;
|
||||
|
||||
const result = {
|
||||
total: 0,
|
||||
success: 0,
|
||||
|
|
@ -314,57 +335,92 @@ export class KeycloakAttributeService {
|
|||
};
|
||||
|
||||
try {
|
||||
// Get profiles with keycloak IDs (ข้าราชการ)
|
||||
const profiles = await this.profileRepo
|
||||
// Build query for profiles with keycloak IDs (ข้าราชการ)
|
||||
const profileQuery = this.profileRepo
|
||||
.createQueryBuilder("p")
|
||||
.where("p.keycloak IS NOT NULL")
|
||||
.andWhere("p.keycloak != :empty", { empty: "" })
|
||||
.take(limit)
|
||||
.getMany();
|
||||
.andWhere("p.keycloak != :empty", { empty: "" });
|
||||
|
||||
// Get profileEmployees with keycloak IDs (ลูกจ้าง)
|
||||
const profileEmployees = await this.profileEmployeeRepo
|
||||
// Build query for profileEmployees with keycloak IDs (ลูกจ้าง)
|
||||
const profileEmployeeQuery = this.profileEmployeeRepo
|
||||
.createQueryBuilder("pe")
|
||||
.where("pe.keycloak IS NOT NULL")
|
||||
.andWhere("pe.keycloak != :empty", { empty: "" })
|
||||
.take(limit)
|
||||
.getMany();
|
||||
.andWhere("pe.keycloak != :empty", { empty: "" });
|
||||
|
||||
// Apply limit if specified (for testing purposes)
|
||||
if (limit !== undefined) {
|
||||
profileQuery.take(limit);
|
||||
profileEmployeeQuery.take(limit);
|
||||
}
|
||||
|
||||
// Get profiles from both tables
|
||||
const [profiles, profileEmployees] = await Promise.all([
|
||||
profileQuery.getMany(),
|
||||
profileEmployeeQuery.getMany(),
|
||||
]);
|
||||
|
||||
const allProfiles = [
|
||||
...profiles.map((p) => ({ profile: p, type: "PROFILE" as const })),
|
||||
...profileEmployees.map((p) => ({ profile: p, type: "PROFILE_EMPLOYEE" as const })),
|
||||
];
|
||||
|
||||
const allProfiles = [...profiles, ...profileEmployees];
|
||||
result.total = allProfiles.length;
|
||||
|
||||
for (const profile of allProfiles) {
|
||||
const keycloakUserId = profile.keycloak;
|
||||
const profileType = profile instanceof Profile ? "PROFILE" : "PROFILE_EMPLOYEE";
|
||||
// Process in parallel with concurrency limit
|
||||
const processedResults = await this.processInParallel(
|
||||
allProfiles,
|
||||
concurrency,
|
||||
async ({ profile, type }, _index) => {
|
||||
const keycloakUserId = profile.keycloak;
|
||||
|
||||
try {
|
||||
const success = await this.syncOnOrganizationChange(profile.id, profileType);
|
||||
if (success) {
|
||||
result.success++;
|
||||
result.details.push({
|
||||
profileId: profile.id,
|
||||
keycloakUserId,
|
||||
status: "success",
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const success = await this.syncOnOrganizationChange(profile.id, type);
|
||||
if (success) {
|
||||
result.success++;
|
||||
return {
|
||||
profileId: profile.id,
|
||||
keycloakUserId,
|
||||
status: "success",
|
||||
};
|
||||
} else {
|
||||
result.failed++;
|
||||
return {
|
||||
profileId: profile.id,
|
||||
keycloakUserId,
|
||||
status: "failed",
|
||||
error: "Sync returned false",
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.failed++;
|
||||
result.details.push({
|
||||
return {
|
||||
profileId: profile.id,
|
||||
keycloakUserId,
|
||||
status: "failed",
|
||||
error: "Sync returned false",
|
||||
});
|
||||
status: "error",
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
},
|
||||
);
|
||||
|
||||
// Separate results from errors
|
||||
for (const resultItem of processedResults) {
|
||||
if ("error" in resultItem) {
|
||||
result.failed++;
|
||||
result.details.push({
|
||||
profileId: profile.id,
|
||||
keycloakUserId,
|
||||
profileId: "unknown",
|
||||
keycloakUserId: "unknown",
|
||||
status: "error",
|
||||
error: error.message,
|
||||
error: JSON.stringify(resultItem.error),
|
||||
});
|
||||
} else {
|
||||
result.details.push(resultItem);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Batch sync completed: total=${result.total}, success=${result.success}, failed=${result.failed}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in batch sync:", error);
|
||||
}
|
||||
|
|
@ -396,10 +452,477 @@ export class KeycloakAttributeService {
|
|||
orgChild3DnaId: user.attributes.orgChild3DnaId?.[0] || "",
|
||||
orgChild4DnaId: user.attributes.orgChild4DnaId?.[0] || "",
|
||||
empType: user.attributes.empType?.[0] || "",
|
||||
prefix: user.attributes.prefix?.[0] || "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error getting Keycloak attributes for user ${keycloakUserId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Keycloak user exists for a profile
|
||||
* Creates user if keycloak field is empty OR if stored keycloak ID doesn't exist in Keycloak
|
||||
*
|
||||
* @param profileId - Profile ID
|
||||
* @param profileType - 'PROFILE' or 'PROFILE_EMPLOYEE'
|
||||
* @returns Object with status and details
|
||||
*/
|
||||
async ensureKeycloakUser(
|
||||
profileId: string,
|
||||
profileType: "PROFILE" | "PROFILE_EMPLOYEE",
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
action: "created" | "verified" | "skipped" | "error";
|
||||
keycloakUserId?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Get profile from database
|
||||
let profile: Profile | ProfileEmployee | null = null;
|
||||
|
||||
if (profileType === "PROFILE") {
|
||||
profile = await this.profileRepo.findOne({ where: { id: profileId } });
|
||||
} else {
|
||||
profile = await this.profileEmployeeRepo.findOne({ where: { id: profileId } });
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return {
|
||||
success: false,
|
||||
action: "error",
|
||||
error: `Profile ${profileId} not found in database`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if citizenId exists
|
||||
if (!profile.citizenId) {
|
||||
return {
|
||||
success: false,
|
||||
action: "skipped",
|
||||
error: "No citizenId found",
|
||||
};
|
||||
}
|
||||
|
||||
// Case 1: keycloak field is empty -> create new user
|
||||
if (!profile.keycloak || profile.keycloak.trim() === "") {
|
||||
const result = await this.createKeycloakUserFromProfile(profile, profileType);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Case 2: keycloak field is not empty -> verify user exists in Keycloak
|
||||
const existingUser = await getUser(profile.keycloak);
|
||||
|
||||
if (!existingUser) {
|
||||
// User doesn't exist in Keycloak, create new one
|
||||
console.log(
|
||||
`Keycloak user ${profile.keycloak} not found in Keycloak, creating new user for profile ${profileId}`,
|
||||
);
|
||||
const result = await this.createKeycloakUserFromProfile(profile, profileType);
|
||||
return result;
|
||||
}
|
||||
|
||||
// User exists in Keycloak, verified
|
||||
return {
|
||||
success: true,
|
||||
action: "verified",
|
||||
keycloakUserId: profile.keycloak,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`Error ensuring Keycloak user for profile ${profileId}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
action: "error",
|
||||
error: error.message || "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Keycloak user from profile data
|
||||
*
|
||||
* @param profile - Profile or ProfileEmployee entity
|
||||
* @param profileType - 'PROFILE' or 'PROFILE_EMPLOYEE'
|
||||
* @returns Object with status and details
|
||||
*/
|
||||
private async createKeycloakUserFromProfile(
|
||||
profile: Profile | ProfileEmployee,
|
||||
profileType: "PROFILE" | "PROFILE_EMPLOYEE",
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
action: "created" | "verified" | "skipped" | "error";
|
||||
keycloakUserId?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Check if user already exists by username (citizenId)
|
||||
const existingUserByUsername = await getUserByUsername(profile.citizenId);
|
||||
if (Array.isArray(existingUserByUsername) && existingUserByUsername.length > 0) {
|
||||
// User already exists with this username, update the keycloak field
|
||||
const existingUserId = existingUserByUsername[0].id;
|
||||
console.log(
|
||||
`User with citizenId ${profile.citizenId} already exists in Keycloak with ID ${existingUserId}`,
|
||||
);
|
||||
|
||||
// Update the keycloak field in database
|
||||
if (profileType === "PROFILE") {
|
||||
await this.profileRepo.update(profile.id, { keycloak: existingUserId });
|
||||
} else {
|
||||
await this.profileEmployeeRepo.update(profile.id, { keycloak: existingUserId });
|
||||
}
|
||||
|
||||
// Assign default USER role to existing user
|
||||
const userRole = await getRoles("USER");
|
||||
if (userRole && typeof userRole === "object" && "id" in userRole && "name" in userRole) {
|
||||
const roleAssigned = await addUserRoles(existingUserId, [
|
||||
{ id: String(userRole.id), name: String(userRole.name) },
|
||||
]);
|
||||
if (roleAssigned) {
|
||||
console.log(`Assigned USER role to existing user ${existingUserId}`);
|
||||
} else {
|
||||
console.warn(`Failed to assign USER role to existing user ${existingUserId}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`USER role not found in Keycloak`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: "verified",
|
||||
keycloakUserId: existingUserId,
|
||||
};
|
||||
}
|
||||
|
||||
// Create new user in Keycloak
|
||||
const createResult = await createUser(profile.citizenId, "P@ssw0rd", {
|
||||
firstName: profile.firstName || "",
|
||||
lastName: profile.lastName || "",
|
||||
email: profile.email || undefined,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
if (!createResult || typeof createResult !== "string") {
|
||||
return {
|
||||
success: false,
|
||||
action: "error",
|
||||
error: "Failed to create user in Keycloak",
|
||||
};
|
||||
}
|
||||
|
||||
const keycloakUserId = createResult;
|
||||
|
||||
// Update the keycloak field in database
|
||||
if (profileType === "PROFILE") {
|
||||
await this.profileRepo.update(profile.id, { keycloak: keycloakUserId });
|
||||
} else {
|
||||
await this.profileEmployeeRepo.update(profile.id, { keycloak: keycloakUserId });
|
||||
}
|
||||
|
||||
// Assign default USER role
|
||||
const userRole = await getRoles("USER");
|
||||
if (userRole && typeof userRole === "object" && "id" in userRole && "name" in userRole) {
|
||||
const roleAssigned = await addUserRoles(keycloakUserId, [
|
||||
{ id: String(userRole.id), name: String(userRole.name) },
|
||||
]);
|
||||
if (roleAssigned) {
|
||||
console.log(`Assigned USER role to user ${keycloakUserId}`);
|
||||
} else {
|
||||
console.warn(`Failed to assign USER role to user ${keycloakUserId}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`USER role not found in Keycloak`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Created Keycloak user for profile ${profile.id} (citizenId: ${profile.citizenId}) with ID ${keycloakUserId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: "created",
|
||||
keycloakUserId,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`Error creating Keycloak user for profile ${profile.id}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
action: "error",
|
||||
error: error.message || "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process items in parallel with concurrency limit
|
||||
*/
|
||||
private async processInParallel<T, R>(
|
||||
items: T[],
|
||||
concurrencyLimit: number,
|
||||
processor: (item: T, index: number) => Promise<R>,
|
||||
): Promise<Array<R | { error: any }>> {
|
||||
const results: Array<R | { error: any }> = [];
|
||||
|
||||
// Process items in batches
|
||||
for (let i = 0; i < items.length; i += concurrencyLimit) {
|
||||
const batch = items.slice(i, i + concurrencyLimit);
|
||||
|
||||
// Process batch in parallel with error handling
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (item, batchIndex) => {
|
||||
try {
|
||||
return await processor(item, i + batchIndex);
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
results.push(...batchResults);
|
||||
|
||||
// Log progress after each batch
|
||||
const completed = Math.min(i + concurrencyLimit, items.length);
|
||||
console.log(`Progress: ${completed}/${items.length}`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch ensure Keycloak users for all profiles
|
||||
* Processes all rows in Profile and ProfileEmployee tables
|
||||
*
|
||||
* @returns Object with total, success, failed counts and details
|
||||
*/
|
||||
async batchEnsureKeycloakUsers(): Promise<{
|
||||
total: number;
|
||||
created: number;
|
||||
verified: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
details: Array<{
|
||||
profileId: string;
|
||||
profileType: string;
|
||||
action: string;
|
||||
keycloakUserId?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}> {
|
||||
const result = {
|
||||
total: 0,
|
||||
created: 0,
|
||||
verified: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
details: [] as Array<{
|
||||
profileId: string;
|
||||
profileType: string;
|
||||
action: string;
|
||||
keycloakUserId?: string;
|
||||
error?: string;
|
||||
}>,
|
||||
};
|
||||
|
||||
try {
|
||||
// Get all profiles from Profile table (ข้าราชการ)
|
||||
const profiles = await this.profileRepo.find({ where: { isLeave: false } }); // Only active profiles
|
||||
|
||||
// Get all profiles from ProfileEmployee table (ลูกจ้าง)
|
||||
const profileEmployees = await this.profileEmployeeRepo.find({ where: { isLeave: false } }); // Only active profiles
|
||||
|
||||
const allProfiles = [
|
||||
...profiles.map((p) => ({ profile: p, type: "PROFILE" as const })),
|
||||
...profileEmployees.map((p) => ({ profile: p, type: "PROFILE_EMPLOYEE" as const })),
|
||||
];
|
||||
|
||||
result.total = allProfiles.length;
|
||||
|
||||
// Process in parallel with concurrency limit
|
||||
const CONCURRENCY_LIMIT = 5; // Adjust based on environment
|
||||
|
||||
const processedResults = await this.processInParallel(
|
||||
allProfiles,
|
||||
CONCURRENCY_LIMIT,
|
||||
async ({ profile, type }) => {
|
||||
const ensureResult = await this.ensureKeycloakUser(profile.id, type);
|
||||
|
||||
// Update counters
|
||||
switch (ensureResult.action) {
|
||||
case "created":
|
||||
result.created++;
|
||||
break;
|
||||
case "verified":
|
||||
result.verified++;
|
||||
break;
|
||||
case "skipped":
|
||||
result.skipped++;
|
||||
break;
|
||||
case "error":
|
||||
result.failed++;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
profileId: profile.id,
|
||||
profileType: type,
|
||||
action: ensureResult.action,
|
||||
keycloakUserId: ensureResult.keycloakUserId,
|
||||
error: ensureResult.error,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Separate results from errors
|
||||
for (const resultItem of processedResults) {
|
||||
if ("error" in resultItem) {
|
||||
result.failed++;
|
||||
result.details.push({
|
||||
profileId: "unknown",
|
||||
profileType: "unknown",
|
||||
action: "error",
|
||||
error: JSON.stringify(resultItem.error),
|
||||
});
|
||||
} else {
|
||||
result.details.push(resultItem);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Batch ensure Keycloak users completed: total=${result.total}, created=${result.created}, verified=${result.verified}, skipped=${result.skipped}, failed=${result.failed}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in batch ensure Keycloak users:", error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear orphaned Keycloak users
|
||||
* Deletes users in Keycloak that are not referenced in Profile or ProfileEmployee tables
|
||||
*
|
||||
* @param skipUsernames - Array of usernames to skip (e.g., ['super_admin'])
|
||||
* @returns Object with counts and details
|
||||
*/
|
||||
async clearOrphanedKeycloakUsers(skipUsernames: string[] = []): Promise<{
|
||||
totalInKeycloak: number;
|
||||
totalInDatabase: number;
|
||||
orphanedCount: number;
|
||||
deleted: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
details: Array<{
|
||||
keycloakUserId: string;
|
||||
username: string;
|
||||
action: "deleted" | "skipped" | "error";
|
||||
error?: string;
|
||||
}>;
|
||||
}> {
|
||||
const result = {
|
||||
totalInKeycloak: 0,
|
||||
totalInDatabase: 0,
|
||||
orphanedCount: 0,
|
||||
deleted: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
details: [] as Array<{
|
||||
keycloakUserId: string;
|
||||
username: string;
|
||||
action: "deleted" | "skipped" | "error";
|
||||
error?: string;
|
||||
}>,
|
||||
};
|
||||
|
||||
try {
|
||||
// Get all keycloak IDs from database (Profile + ProfileEmployee)
|
||||
const profiles = await this.profileRepo
|
||||
.createQueryBuilder("p")
|
||||
.where("p.keycloak IS NOT NULL")
|
||||
.andWhere("p.keycloak != :empty", { empty: "" })
|
||||
.getMany();
|
||||
|
||||
const profileEmployees = await this.profileEmployeeRepo
|
||||
.createQueryBuilder("pe")
|
||||
.where("pe.keycloak IS NOT NULL")
|
||||
.andWhere("pe.keycloak != :empty", { empty: "" })
|
||||
.getMany();
|
||||
|
||||
// Create a Set of all keycloak IDs in database for O(1) lookup
|
||||
const databaseKeycloakIds = new Set<string>();
|
||||
for (const p of profiles) {
|
||||
if (p.keycloak) databaseKeycloakIds.add(p.keycloak);
|
||||
}
|
||||
for (const pe of profileEmployees) {
|
||||
if (pe.keycloak) databaseKeycloakIds.add(pe.keycloak);
|
||||
}
|
||||
|
||||
result.totalInDatabase = databaseKeycloakIds.size;
|
||||
|
||||
// Get all users from Keycloak with pagination to avoid response size limits
|
||||
const keycloakUsers = await getAllUsersPaginated();
|
||||
if (!keycloakUsers || typeof keycloakUsers !== "object") {
|
||||
throw new Error("Failed to get users from Keycloak");
|
||||
}
|
||||
|
||||
result.totalInKeycloak = keycloakUsers.length;
|
||||
|
||||
// Find orphaned users (in Keycloak but not in database)
|
||||
const orphanedUsers = keycloakUsers.filter((user: any) => !databaseKeycloakIds.has(user.id));
|
||||
result.orphanedCount = orphanedUsers.length;
|
||||
|
||||
// Delete orphaned users (skip protected ones)
|
||||
for (const user of orphanedUsers) {
|
||||
const username = user.username;
|
||||
const userId = user.id;
|
||||
|
||||
// Check if user should be skipped
|
||||
if (skipUsernames.includes(username)) {
|
||||
result.skipped++;
|
||||
result.details.push({
|
||||
keycloakUserId: userId,
|
||||
username,
|
||||
action: "skipped",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete user from Keycloak
|
||||
try {
|
||||
const deleteSuccess = await deleteUser(userId);
|
||||
if (deleteSuccess) {
|
||||
result.deleted++;
|
||||
result.details.push({
|
||||
keycloakUserId: userId,
|
||||
username,
|
||||
action: "deleted",
|
||||
});
|
||||
} else {
|
||||
result.failed++;
|
||||
result.details.push({
|
||||
keycloakUserId: userId,
|
||||
username,
|
||||
action: "error",
|
||||
error: "Failed to delete user from Keycloak",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.failed++;
|
||||
result.details.push({
|
||||
keycloakUserId: userId,
|
||||
username,
|
||||
action: "error",
|
||||
error: error.message || "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Clear orphaned Keycloak users completed: totalInKeycloak=${result.totalInKeycloak}, totalInDatabase=${result.totalInDatabase}, orphaned=${result.orphanedCount}, deleted=${result.deleted}, skipped=${result.skipped}, failed=${result.failed}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in clear orphaned Keycloak users:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue