From a487b73c3bcfbf7402adc9933fa6f9bf514929b8 Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Wed, 4 Feb 2026 16:52:39 +0700 Subject: [PATCH] setup auth middleware and sync code --- src/controllers/KeycloakSyncController.ts | 198 +++++++++++ src/keycloak/index.ts | 64 ++++ src/middlewares/auth.ts | 9 + src/middlewares/user.ts | 8 + src/services/KeycloakAttributeService.ts | 405 ++++++++++++++++++++++ 5 files changed, 684 insertions(+) create mode 100644 src/controllers/KeycloakSyncController.ts create mode 100644 src/services/KeycloakAttributeService.ts diff --git a/src/controllers/KeycloakSyncController.ts b/src/controllers/KeycloakSyncController.ts new file mode 100644 index 00000000..2a55983c --- /dev/null +++ b/src/controllers/KeycloakSyncController.ts @@ -0,0 +1,198 @@ +import { Controller, Post, Get, Route, Security, Tags, Path, Request, Response, Query } 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") +@Security("bearerAuth") +@Response( + HttpStatus.INTERNAL_SERVER_ERROR, + "เกิดข้อผิดพลาด ไม่สามารถดำเนินการได้ กรุณาลองใหม่ในภายหลัง", +) +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 + * + * @summary Sync profileId and rootDnaId to Keycloak for current user + */ + @Post("sync-me") + async syncCurrentUser(@Request() request: RequestWithUser) { + const keycloakUserId = request.user.sub; + + if (!keycloakUserId) { + throw new HttpError(HttpStatus.UNAUTHORIZED, "ไม่พบ Keycloak user ID"); + } + + // Get attributes from database before sync + const dbAttrs = await this.keycloakAttributeService.getUserProfileAttributes(keycloakUserId); + + const success = await this.keycloakAttributeService.syncUserAttributes(keycloakUserId); + + if (!success) { + throw new HttpError( + HttpStatus.INTERNAL_SERVER_ERROR, + "ไม่สามารถ sync ข้อมูลไปยัง Keycloak ได้ กรุณาติดต่อผู้ดูแลระบบ", + ); + } + + // Verify sync by fetching attributes from Keycloak after update + const kcAttrsAfter = + await this.keycloakAttributeService.getCurrentKeycloakAttributes(keycloakUserId); + + return new HttpSuccess({ + message: "Sync ข้อมูลสำเร็จ", + syncedToKeycloak: !!kcAttrsAfter?.profileId, + databaseAttributes: dbAttrs, + keycloakAttributesAfter: kcAttrsAfter, + }); + } + + /** + * Get current attributes of the logged-in user + * + * @summary Get current profileId and rootDnaId from Keycloak + */ + @Get("my-attributes") + async getMyAttributes(@Request() request: RequestWithUser) { + const keycloakUserId = request.user.sub; + + if (!keycloakUserId) { + throw new HttpError(HttpStatus.UNAUTHORIZED, "ไม่พบ Keycloak user ID"); + } + + const keycloakAttributes = + await this.keycloakAttributeService.getCurrentKeycloakAttributes(keycloakUserId); + const dbAttributes = + await this.keycloakAttributeService.getUserProfileAttributes(keycloakUserId); + + return new HttpSuccess({ + keycloakAttributes, + databaseAttributes: dbAttributes, + }); + } + + /** + * Sync attributes for a specific profile (Admin only) + * + * @summary Sync profileId and rootDnaId to Keycloak by profile ID (ADMIN) + * + * @param {string} profileId Profile ID + * @param {string} profileType Profile type (PROFILE or PROFILE_EMPLOYEE) + */ + @Post("sync-profile/:profileId") + async syncByProfileId( + @Path() profileId: string, + @Query() profileType: "PROFILE" | "PROFILE_EMPLOYEE" = "PROFILE", + ) { + if (!["PROFILE", "PROFILE_EMPLOYEE"].includes(profileType)) { + throw new HttpError( + HttpStatus.BAD_REQUEST, + "profileType ต้องเป็น PROFILE หรือ PROFILE_EMPLOYEE เท่านั้น", + ); + } + + const success = await this.keycloakAttributeService.syncOnOrganizationChange( + profileId, + profileType, + ); + + if (!success) { + throw new HttpError( + HttpStatus.INTERNAL_SERVER_ERROR, + "ไม่สามารถ sync ข้อมูลไปยัง Keycloak ได้ หรือไม่พบข้อมูล profile", + ); + } + + return new HttpSuccess({ message: "Sync ข้อมูลสำเร็จ" }); + } + + /** + * Batch sync all users (Admin only) + * + * @summary Batch sync all users to Keycloak (ADMIN) + * + * @param {number} limit Maximum number of users to sync + */ + @Post("sync-all") + async syncAll(@Query() limit: number = 100) { + if (limit > 500) { + throw new HttpError(HttpStatus.BAD_REQUEST, "limit ต้องไม่เกิน 500"); + } + + const result = await this.keycloakAttributeService.batchSyncUsers(limit); + + return new HttpSuccess({ + message: "Batch sync เสร็จสิ้น", + total: result.total, + success: result.success, + failed: result.failed, + details: result.details, + }); + } + + /** + * ตรวจสอบสถานะ Keycloak Mapper + * + * @summary ตรวจสอบว่า profileId และ rootDnaId ออกมาใน token หรือไม่ + */ + @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; + + 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" + : "ทุกอย่างปกติ", + }, + }); + } +} diff --git a/src/keycloak/index.ts b/src/keycloak/index.ts index a81e2af9..d977c42d 100644 --- a/src/keycloak/index.ts +++ b/src/keycloak/index.ts @@ -772,6 +772,70 @@ export async function changeUserPassword(userId: string, newPassword: string) { } } +/** + * Update user attributes in Keycloak + * + * @param userId - Keycloak user ID + * @param attributes - Object containing attribute names and their values (as arrays) + * @returns true if success, false otherwise + */ +export async function updateUserAttributes( + userId: string, + attributes: Record, +): Promise { + try { + // Get existing user data to preserve other attributes + const existingUser = await getUser(userId); + + if (!existingUser) { + console.error(`User ${userId} not found in Keycloak`); + return false; + } + + // Merge existing attributes with new attributes + // Keycloak requires id to be present in the payload + const updatedAttributes = { + id: existingUser.id, + enabled: existingUser.enabled ?? true, + attributes: { + ...(existingUser.attributes || {}), + ...attributes, + }, + }; + + console.log(`[updateUserAttributes] Sending to Keycloak:`, JSON.stringify(updatedAttributes, null, 2)); + + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}`, { + headers: { + authorization: `Bearer ${await getToken()}`, + "content-type": "application/json", + }, + method: "PUT", + body: JSON.stringify(updatedAttributes), + }).catch((e) => { + console.error(`[updateUserAttributes] Network error:`, e); + return null; + }); + + if (!res) { + console.error(`[updateUserAttributes] No response from Keycloak`); + return false; + } + + if (!res.ok) { + const errorText = await res.text(); + console.error(`[updateUserAttributes] Keycloak Error (${res.status}):`, errorText); + return false; + } + + console.log(`[updateUserAttributes] Successfully updated attributes for user ${userId}`); + return true; + } catch (error) { + console.error(`[updateUserAttributes] Error updating attributes for user ${userId}:`, error); + return false; + } +} + // Function to reset password export async function resetPassword(username: string) { try { diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index c27e6188..80fc9e92 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -75,6 +75,15 @@ export async function expressAuthentication( request.app.locals.logData.userName = payload.name; request.app.locals.logData.user = payload.preferred_username; + // เก็บค่า profileId และ orgRootDnaId จาก token (ใช้ค่าว่างถ้าไม่มี) + request.app.locals.logData.profileId = payload.profileId ?? ""; + request.app.locals.logData.orgRootDnaId = payload.orgRootDnaId ?? ""; + request.app.locals.logData.orgChild1DnaId = payload.orgChild1DnaId ?? ""; + request.app.locals.logData.orgChild2DnaId = payload.orgChild2DnaId ?? ""; + request.app.locals.logData.orgChild3DnaId = payload.orgChild3DnaId ?? ""; + request.app.locals.logData.orgChild4DnaId = payload.orgChild4DnaId ?? ""; + request.app.locals.logData.empType = payload.empType ?? ""; + return payload; } diff --git a/src/middlewares/user.ts b/src/middlewares/user.ts index e5c48d9a..43ac80a3 100644 --- a/src/middlewares/user.ts +++ b/src/middlewares/user.ts @@ -9,6 +9,14 @@ export type RequestWithUser = Request & { preferred_username: string; email: string; role: string[]; + profileId?: string; + orgRootDnaId?: string; + orgChild1DnaId?: string; + orgChild2DnaId?: string; + orgChild3DnaId?: string; + orgChild4DnaId?: string; + empType?: string; + scope?: string; }; }; diff --git a/src/services/KeycloakAttributeService.ts b/src/services/KeycloakAttributeService.ts new file mode 100644 index 00000000..3ce04b1e --- /dev/null +++ b/src/services/KeycloakAttributeService.ts @@ -0,0 +1,405 @@ +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 { OrgRoot } from "../entities/OrgRoot"; +import { getUser, updateUserAttributes } from "../keycloak"; +import { OrgRevision } from "../entities/OrgRevision"; + +export interface UserProfileAttributes { + profileId: string | null; + orgRootDnaId: string | null; + orgChild1DnaId: string | null; + orgChild2DnaId: string | null; + orgChild3DnaId: string | null; + orgChild4DnaId: string | null; + empType: string | null; +} + +/** + * Keycloak Attribute Service + * Service for syncing profileId and orgRootDnaId to Keycloak user attributes + */ +export class KeycloakAttributeService { + private profileRepo = AppDataSource.getRepository(Profile); + private profileEmployeeRepo = AppDataSource.getRepository(ProfileEmployee); + // private posMasterRepo = AppDataSource.getRepository(PosMaster); + // private employeePosMasterRepo = AppDataSource.getRepository(EmployeePosMaster); + // private orgRootRepo = AppDataSource.getRepository(OrgRoot); + private orgRevisionRepo = AppDataSource.getRepository(OrgRevision); + + /** + * Get profile attributes (profileId and orgRootDnaId) from database + * Searches in Profile table first (ข้าราชการ), then ProfileEmployee (ลูกจ้าง) + * + * @param keycloakUserId - Keycloak user ID + * @returns UserProfileAttributes with profileId and orgRootDnaId + */ + async getUserProfileAttributes(keycloakUserId: string): Promise { + // First, try to find in Profile (ข้าราชการ) + const revisionCurrent = await this.orgRevisionRepo.findOne({ + where: { orgRevisionIsCurrent: true }, + }); + const revisionId = revisionCurrent ? revisionCurrent.id : null; + const profileResult = await this.profileRepo + .createQueryBuilder("p") + .leftJoinAndSelect("p.current_holders", "pm") + .leftJoinAndSelect("pm.orgRoot", "orgRoot") + .leftJoinAndSelect("pm.orgChild1", "orgChild1") + .leftJoinAndSelect("pm.orgChild2", "orgChild2") + .leftJoinAndSelect("pm.orgChild3", "orgChild3") + .leftJoinAndSelect("pm.orgChild4", "orgChild4") + .where("p.keycloak = :keycloakUserId", { keycloakUserId }) + .andWhere("orgRoot.orgRevisionId = :revisionId", { revisionId }) + .getOne(); + + if ( + profileResult && + profileResult.current_holders && + profileResult.current_holders.length > 0 + ) { + const currentPos = profileResult.current_holders[0]; + const orgRootDnaId = currentPos.orgRoot?.ancestorDNA || ""; + const orgChild1DnaId = currentPos.orgChild1?.ancestorDNA || ""; + const orgChild2DnaId = currentPos.orgChild2?.ancestorDNA || ""; + const orgChild3DnaId = currentPos.orgChild3?.ancestorDNA || ""; + const orgChild4DnaId = currentPos.orgChild4?.ancestorDNA || ""; + return { + profileId: profileResult.id, + orgRootDnaId, + orgChild1DnaId, + orgChild2DnaId, + orgChild3DnaId, + orgChild4DnaId, + empType: "OFFICER", + }; + } + + // If not found in Profile, try ProfileEmployee (ลูกจ้าง) + const profileEmployeeResult = await this.profileEmployeeRepo + .createQueryBuilder("pe") + .leftJoinAndSelect("pe.current_holders", "epm") + .leftJoinAndSelect("epm.orgRoot", "org") + .leftJoinAndSelect("epm.orgChild1", "orgChild1") + .leftJoinAndSelect("epm.orgChild2", "orgChild2") + .leftJoinAndSelect("epm.orgChild3", "orgChild3") + .leftJoinAndSelect("epm.orgChild4", "orgChild4") + .where("pe.keycloak = :keycloakUserId", { keycloakUserId }) + .getOne(); + + if ( + profileEmployeeResult && + profileEmployeeResult.current_holders && + profileEmployeeResult.current_holders.length > 0 + ) { + const currentPos = profileEmployeeResult.current_holders[0]; + const orgRootDnaId = currentPos.orgRoot?.ancestorDNA || ""; + const orgChild1DnaId = currentPos.orgChild1?.ancestorDNA || ""; + const orgChild2DnaId = currentPos.orgChild2?.ancestorDNA || ""; + const orgChild3DnaId = currentPos.orgChild3?.ancestorDNA || ""; + const orgChild4DnaId = currentPos.orgChild4?.ancestorDNA || ""; + return { + profileId: profileEmployeeResult.id, + orgRootDnaId, + orgChild1DnaId, + orgChild2DnaId, + orgChild3DnaId, + orgChild4DnaId, + empType: profileEmployeeResult.employeeClass, + }; + } + + // Return null values if no profile found + return { + profileId: null, + orgRootDnaId: null, + orgChild1DnaId: null, + orgChild2DnaId: null, + orgChild3DnaId: null, + orgChild4DnaId: null, + empType: null, + }; + } + + /** + * Get profile attributes by profile ID directly + * Used for syncing specific profiles + * + * @param profileId - Profile ID + * @param profileType - 'PROFILE' for ข้าราชการ or 'PROFILE_EMPLOYEE' for ลูกจ้าง + * @returns UserProfileAttributes with profileId and orgRootDnaId + */ + async getAttributesByProfileId( + profileId: string, + profileType: "PROFILE" | "PROFILE_EMPLOYEE", + ): Promise { + const revisionCurrent = await this.orgRevisionRepo.findOne({ + where: { orgRevisionIsCurrent: true }, + }); + const revisionId = revisionCurrent ? revisionCurrent.id : null; + if (profileType === "PROFILE") { + const profileResult = await this.profileRepo + .createQueryBuilder("p") + .leftJoinAndSelect("p.current_holders", "pm") + .leftJoinAndSelect("pm.orgRoot", "orgRoot") + .leftJoinAndSelect("pm.orgChild1", "orgChild1") + .leftJoinAndSelect("pm.orgChild2", "orgChild2") + .leftJoinAndSelect("pm.orgChild3", "orgChild3") + .leftJoinAndSelect("pm.orgChild4", "orgChild4") + .where("p.id = :profileId", { profileId }) + .andWhere("orgRoot.orgRevisionId = :revisionId", { revisionId }) + .getOne(); + + if ( + profileResult && + profileResult.current_holders && + profileResult.current_holders.length > 0 + ) { + const currentPos = profileResult.current_holders[0]; + const orgRootDnaId = currentPos.orgRoot?.ancestorDNA || ""; + const orgChild1DnaId = currentPos.orgChild1?.ancestorDNA || ""; + const orgChild2DnaId = currentPos.orgChild2?.ancestorDNA || ""; + const orgChild3DnaId = currentPos.orgChild3?.ancestorDNA || ""; + const orgChild4DnaId = currentPos.orgChild4?.ancestorDNA || ""; + return { + profileId: profileResult.id, + orgRootDnaId, + orgChild1DnaId, + orgChild2DnaId, + orgChild3DnaId, + orgChild4DnaId, + empType: "OFFICER", + }; + } + } else { + const profileEmployeeResult = await this.profileEmployeeRepo + .createQueryBuilder("pe") + .leftJoinAndSelect("pe.current_holders", "epm") + .leftJoinAndSelect("epm.orgRoot", "org") + .leftJoinAndSelect("pm.orgChild1", "orgChild1") + .leftJoinAndSelect("pm.orgChild2", "orgChild2") + .leftJoinAndSelect("pm.orgChild3", "orgChild3") + .leftJoinAndSelect("pm.orgChild4", "orgChild4") + .where("pe.id = :profileId", { profileId }) + .getOne(); + + if ( + profileEmployeeResult && + profileEmployeeResult.current_holders && + profileEmployeeResult.current_holders.length > 0 + ) { + const currentPos = profileEmployeeResult.current_holders[0]; + const orgRootDnaId = currentPos.orgRoot?.ancestorDNA || ""; + const orgChild1DnaId = currentPos.orgChild1?.ancestorDNA || ""; + const orgChild2DnaId = currentPos.orgChild2?.ancestorDNA || ""; + const orgChild3DnaId = currentPos.orgChild3?.ancestorDNA || ""; + const orgChild4DnaId = currentPos.orgChild4?.ancestorDNA || ""; + + return { + profileId: profileEmployeeResult.id, + orgRootDnaId, + orgChild1DnaId, + orgChild2DnaId, + orgChild3DnaId, + orgChild4DnaId, + empType: profileEmployeeResult.employeeClass, + }; + } + } + + return { + profileId: null, + orgRootDnaId: null, + orgChild1DnaId: null, + orgChild2DnaId: null, + orgChild3DnaId: null, + orgChild4DnaId: null, + empType: null, + }; + } + + /** + * Sync user attributes to Keycloak + * + * @param keycloakUserId - Keycloak user ID + * @returns true if sync successful, false otherwise + */ + async syncUserAttributes(keycloakUserId: string): Promise { + try { + const attributes = await this.getUserProfileAttributes(keycloakUserId); + + if (!attributes.profileId) { + console.log(`No profile found for Keycloak user ${keycloakUserId}`); + return false; + } + + // Prepare attributes for Keycloak (must be arrays) + const keycloakAttributes: Record = { + profileId: [attributes.profileId], + orgRootDnaId: [attributes.orgRootDnaId || ""], + orgChild1DnaId: [attributes.orgChild1DnaId || ""], + orgChild2DnaId: [attributes.orgChild2DnaId || ""], + orgChild3DnaId: [attributes.orgChild3DnaId || ""], + orgChild4DnaId: [attributes.orgChild4DnaId || ""], + empType: [attributes.empType || ""], + }; + + const success = await updateUserAttributes(keycloakUserId, keycloakAttributes); + + if (success) { + console.log(`Synced attributes for Keycloak user ${keycloakUserId}:`, attributes); + } + + return success; + } catch (error) { + console.error(`Error syncing attributes for Keycloak user ${keycloakUserId}:`, error); + return false; + } + } + + /** + * Sync attributes when organization changes + * This is called when a user moves to a different organization + * + * @param profileId - Profile ID + * @param profileType - 'PROFILE' for ข้าราชการ or 'PROFILE_EMPLOYEE' for ลูกจ้าง + * @returns true if sync successful, false otherwise + */ + async syncOnOrganizationChange( + profileId: string, + profileType: "PROFILE" | "PROFILE_EMPLOYEE", + ): Promise { + try { + // Get the keycloak userId from the profile + let keycloakUserId: string | null = null; + + if (profileType === "PROFILE") { + const profile = await this.profileRepo.findOne({ where: { id: profileId } }); + keycloakUserId = profile?.keycloak || ""; + } else { + const profileEmployee = await this.profileEmployeeRepo.findOne({ + where: { id: profileId }, + }); + keycloakUserId = profileEmployee?.keycloak || ""; + } + + if (!keycloakUserId) { + console.log(`No Keycloak user ID found for profile ${profileId}`); + return false; + } + + return await this.syncUserAttributes(keycloakUserId); + } catch (error) { + console.error(`Error syncing organization change for profile ${profileId}:`, error); + return false; + } + } + + /** + * Batch sync multiple users + * Useful for initial sync or periodic updates + * + * @param limit - Maximum number of users to sync (default: 100) + * @returns Object with success count and details + */ + async batchSyncUsers( + limit: number = 100, + ): Promise<{ total: number; success: number; failed: number; details: any[] }> { + const result = { + total: 0, + success: 0, + failed: 0, + details: [] as any[], + }; + + try { + // Get profiles with keycloak IDs (ข้าราชการ) + const profiles = await this.profileRepo + .createQueryBuilder("p") + .where("p.keycloak IS NOT NULL") + .andWhere("p.keycloak != :empty", { empty: "" }) + .take(limit) + .getMany(); + + // Get profileEmployees with keycloak IDs (ลูกจ้าง) + const profileEmployees = await this.profileEmployeeRepo + .createQueryBuilder("pe") + .where("pe.keycloak IS NOT NULL") + .andWhere("pe.keycloak != :empty", { empty: "" }) + .take(limit) + .getMany(); + + 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"; + + try { + const success = await this.syncOnOrganizationChange(profile.id, profileType); + if (success) { + result.success++; + result.details.push({ + profileId: profile.id, + keycloakUserId, + status: "success", + }); + } else { + result.failed++; + result.details.push({ + profileId: profile.id, + keycloakUserId, + status: "failed", + error: "Sync returned false", + }); + } + } catch (error: any) { + result.failed++; + result.details.push({ + profileId: profile.id, + keycloakUserId, + status: "error", + error: error.message, + }); + } + } + } catch (error) { + console.error("Error in batch sync:", error); + } + + return result; + } + + /** + * Get current Keycloak attributes for a user + * + * @param keycloakUserId - Keycloak user ID + * @returns Current attributes from Keycloak + */ + async getCurrentKeycloakAttributes( + keycloakUserId: string, + ): Promise { + try { + const user = await getUser(keycloakUserId); + + if (!user || !user.attributes) { + return null; + } + + return { + profileId: user.attributes.profileId?.[0] || "", + orgRootDnaId: user.attributes.orgRootDnaId?.[0] || "", + orgChild1DnaId: user.attributes.orgChild1DnaId?.[0] || "", + orgChild2DnaId: user.attributes.orgChild2DnaId?.[0] || "", + orgChild3DnaId: user.attributes.orgChild3DnaId?.[0] || "", + orgChild4DnaId: user.attributes.orgChild4DnaId?.[0] || "", + empType: user.attributes.empType?.[0] || "", + }; + } catch (error) { + console.error(`Error getting Keycloak attributes for user ${keycloakUserId}:`, error); + return null; + } + } +}