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 { createUser, getUser, getUserByUsername, updateUserAttributes, deleteUser, getRoles, addUserRoles, getAllUsersPaginated, } 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; prefix?: 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", prefix: profileResult.prefix, }; } // 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, prefix: profileEmployeeResult.prefix, }; } // Return null values if no profile found return { profileId: null, orgRootDnaId: null, orgChild1DnaId: null, orgChild2DnaId: null, orgChild3DnaId: null, orgChild4DnaId: null, empType: null, prefix: 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", prefix: profileResult.prefix, }; } } 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, prefix: profileEmployeeResult.prefix, }; } } return { profileId: null, orgRootDnaId: null, orgChild1DnaId: null, orgChild2DnaId: null, orgChild3DnaId: null, orgChild4DnaId: null, empType: null, prefix: 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 || ""], prefix: [attributes.prefix || ""], }; 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 with unlimited count and parallel processing * Useful for initial sync or periodic updates * * @param options - Optional configuration (limit for testing, concurrency for parallel processing) * @returns Object with success count and details */ 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, failed: 0, details: [] as any[], }; try { // Build query for profiles with keycloak IDs (ข้าราชการ) const profileQuery = this.profileRepo .createQueryBuilder("p") .where("p.keycloak IS NOT NULL") .andWhere("p.keycloak != :empty", { empty: "" }); // Build query for profileEmployees with keycloak IDs (ลูกจ้าง) const profileEmployeeQuery = this.profileEmployeeRepo .createQueryBuilder("pe") .where("pe.keycloak IS NOT NULL") .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 })), ]; result.total = allProfiles.length; // 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, 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++; return { profileId: profile.id, keycloakUserId, status: "error", error: error.message, }; } }, ); // Separate results from errors for (const resultItem of processedResults) { if ("error" in resultItem) { result.failed++; result.details.push({ profileId: "unknown", keycloakUserId: "unknown", status: "error", 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); } 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] || "", 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( items: T[], concurrencyLimit: number, processor: (item: T, index: number) => Promise, ): Promise> { const results: Array = []; // 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(); 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; } }