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 { createUserHaveProfile, getUser, getUserByUsername, updateUserAttributes, deleteUser, getRoles, addUserRoles, getAllUsersPaginated, withRetry, RateLimiter, } from "../keycloak"; import { OrgRevision } from "../entities/OrgRevision"; import { SyncProgressManager, SyncProgressState } from "../utils/sync-progress"; 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 (ลูกจ้าง) // First, get the profileEmployee to check employeeClass const profileEmployeeBasic = await this.profileEmployeeRepo .createQueryBuilder("pe") .where("pe.keycloak = :keycloakUserId", { keycloakUserId }) .getOne(); if (!profileEmployeeBasic) { // Return null values if no profile found return { profileId: null, orgRootDnaId: null, orgChild1DnaId: null, orgChild2DnaId: null, orgChild3DnaId: null, orgChild4DnaId: null, empType: null, prefix: null, }; } // Check employeeClass to determine which table to query const isPermEmployee = profileEmployeeBasic.employeeClass === "PERM"; if (isPermEmployee) { // ลูกจ้างประจำ (PERM) - ใช้ EmployeePosMaster const profileEmployeeResult = await this.profileEmployeeRepo .createQueryBuilder("pe") .leftJoinAndSelect("pe.current_holders", "epm") .leftJoinAndSelect("epm.orgRoot", "orgRoot") .leftJoinAndSelect("epm.orgChild1", "orgChild1") .leftJoinAndSelect("epm.orgChild2", "orgChild2") .leftJoinAndSelect("epm.orgChild3", "orgChild3") .leftJoinAndSelect("epm.orgChild4", "orgChild4") .where("pe.keycloak = :keycloakUserId", { keycloakUserId }) .andWhere("orgRoot.orgRevisionId = :revisionId", { revisionId }) .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, }; } } else { // ลูกจ้างชั่วคราว (TEMP) - ใช้ EmployeeTempPosMaster const profileEmployeeResult = await this.profileEmployeeRepo .createQueryBuilder("pe") .leftJoinAndSelect("pe.current_holderTemps", "etpm") .leftJoinAndSelect("etpm.orgRoot", "orgRoot") .leftJoinAndSelect("etpm.orgChild1", "orgChild1") .leftJoinAndSelect("etpm.orgChild2", "orgChild2") .leftJoinAndSelect("etpm.orgChild3", "orgChild3") .leftJoinAndSelect("etpm.orgChild4", "orgChild4") .where("pe.keycloak = :keycloakUserId", { keycloakUserId }) .andWhere("orgRoot.orgRevisionId = :revisionId", { revisionId }) .getOne(); if ( profileEmployeeResult && profileEmployeeResult.current_holderTemps && profileEmployeeResult.current_holderTemps.length > 0 ) { const currentPos = profileEmployeeResult.current_holderTemps[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 { // First, get the profileEmployee to check employeeClass const profileEmployeeBasic = await this.profileEmployeeRepo .createQueryBuilder("pe") .where("pe.id = :profileId", { profileId }) .getOne(); if (!profileEmployeeBasic) { return { profileId: null, orgRootDnaId: null, orgChild1DnaId: null, orgChild2DnaId: null, orgChild3DnaId: null, orgChild4DnaId: null, empType: null, prefix: null, }; } // Check employeeClass to determine which table to query const isPermEmployee = profileEmployeeBasic.employeeClass === "PERM"; if (isPermEmployee) { // ลูกจ้างประจำ (PERM) - ใช้ EmployeePosMaster const profileEmployeeResult = await this.profileEmployeeRepo .createQueryBuilder("pe") .leftJoinAndSelect("pe.current_holders", "epm") .leftJoinAndSelect("epm.orgRoot", "orgRoot") .leftJoinAndSelect("epm.orgChild1", "orgChild1") .leftJoinAndSelect("epm.orgChild2", "orgChild2") .leftJoinAndSelect("epm.orgChild3", "orgChild3") .leftJoinAndSelect("epm.orgChild4", "orgChild4") .where("pe.id = :profileId", { profileId }) .andWhere("orgRoot.orgRevisionId = :revisionId", { revisionId }) .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, }; } } else { // ลูกจ้างชั่วคราว (TEMP) - ใช้ EmployeeTempPosMaster const profileEmployeeResult = await this.profileEmployeeRepo .createQueryBuilder("pe") .leftJoinAndSelect("pe.current_holderTemps", "etpm") .leftJoinAndSelect("etpm.orgRoot", "orgRoot") .leftJoinAndSelect("etpm.orgChild1", "orgChild1") .leftJoinAndSelect("etpm.orgChild2", "orgChild2") .leftJoinAndSelect("etpm.orgChild3", "orgChild3") .leftJoinAndSelect("etpm.orgChild4", "orgChild4") .where("pe.id = :profileId", { profileId }) .andWhere("orgRoot.orgRevisionId = :revisionId", { revisionId }) .getOne(); if ( profileEmployeeResult && profileEmployeeResult.current_holderTemps && profileEmployeeResult.current_holderTemps.length > 0 ) { const currentPos = profileEmployeeResult.current_holderTemps[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; } } /** * Check if Keycloak user has empty/null empType attribute * @param keycloakUserId - Keycloak user ID * @returns Object with isEmpty flag and currentEmpType value */ async checkEmpTypeEmpty(keycloakUserId: string): Promise<{ isEmpty: boolean; currentEmpType?: string; }> { try { const user = await getUser(keycloakUserId); if (!user || !user.attributes) { return { isEmpty: true }; } const empType = user.attributes.empType?.[0]; return { isEmpty: !empType || empType.trim() === "", currentEmpType: empType || "", }; } catch (error) { console.error(`[checkEmpTypeEmpty] Error for user ${keycloakUserId}:`, error); return { isEmpty: true }; // Assume empty on error } } /** * Sync profiles with missing empType for a specific month * @param options - Sync configuration * @returns Sync results summary */ async syncMissingEmpTypeByMonth(options: { month: string; // "YYYY-MM" format profileType?: "PROFILE" | "PROFILE_EMPLOYEE"; dryRun?: boolean; concurrency?: number; rateLimit?: number; }): Promise<{ month: string; profileType: string; totalProfiles: number; profilesChecked: number; missingEmpType: number; syncSuccess: number; syncFailed: number; skipped: number; executionTime: string; dryRun: boolean; }> { const startTime = Date.now(); const { month, profileType = "PROFILE", dryRun = false, concurrency = 5, rateLimit = 10, } = options; const result = { month, profileType, totalProfiles: 0, profilesChecked: 0, missingEmpType: 0, syncSuccess: 0, syncFailed: 0, skipped: 0, executionTime: "", dryRun, }; let rateLimiter: RateLimiter | null = null; try { // Parse month (YYYY-MM) to date range const [year, monthNum] = month.split("-").map(Number); const startDate = new Date(Date.UTC(year, monthNum - 1, 1, 0, 0, 0)); const endDate = new Date(Date.UTC(year, monthNum, 0, 23, 59, 59, 999)); console.log( `[syncMissingEmpTypeByMonth] Processing ${profileType} for ${month} (${startDate.toISOString()} to ${endDate.toISOString()})`, ); // Initialize rate limiter if rate limiting is enabled if (rateLimit && rateLimit > 0) { rateLimiter = new RateLimiter(rateLimit); console.log(`[syncMissingEmpTypeByMonth] Rate limiting enabled: ${rateLimit} requests/second`); } // Select repository based on profile type const repo = profileType === "PROFILE" ? this.profileRepo : this.profileEmployeeRepo; // Query profiles updated within the month const profiles = await repo .createQueryBuilder("p") .where("p.keycloak IS NOT NULL") .andWhere("p.keycloak != :empty", { empty: "" }) .andWhere("p.lastUpdatedAt BETWEEN :start AND :end", { start: startDate, end: endDate, }) .orderBy("p.lastUpdatedAt", "ASC") .getMany(); result.totalProfiles = profiles.length; console.log(`[syncMissingEmpTypeByMonth] Found ${profiles.length} profiles to check`); if (profiles.length === 0) { result.executionTime = `${((Date.now() - startTime) / 1000).toFixed(2)}s`; return result; } // Process profiles in parallel with concurrency limit for (let i = 0; i < profiles.length; i += concurrency) { const batch = profiles.slice(i, i + concurrency); await Promise.all( batch.map(async (profile) => { // Apply rate limiting if enabled if (rateLimiter) { await rateLimiter.throttle(); } const keycloakUserId = profile.keycloak; if (!keycloakUserId) { return { profileId: profile.id, status: "skipped" as const, reason: "No keycloak ID", }; } try { // Check if empType is empty in Keycloak const { isEmpty, currentEmpType } = await this.checkEmpTypeEmpty(keycloakUserId); result.profilesChecked++; if (!isEmpty) { result.skipped++; return { profileId: profile.id, status: "skipped" as const, reason: "empType already exists", empType: currentEmpType, }; } result.missingEmpType++; if (dryRun) { return { profileId: profile.id, status: "skipped" as const, reason: "dry run", wouldSync: true, }; } // Sync the profile const success = await withRetry( async () => this.syncOnOrganizationChange(profile.id, profileType), 3, // maxRetries 1000, // baseDelay ); if (success) { result.syncSuccess++; return { profileId: profile.id, status: "synced" as const, }; } else { result.syncFailed++; return { profileId: profile.id, status: "failed" as const, reason: "Sync returned false", }; } } catch (error: any) { result.syncFailed++; return { profileId: profile.id, status: "failed" as const, reason: error.message || "Unknown error", }; } }), ); // Log progress every 50 profiles const completed = Math.min(i + concurrency, profiles.length); if (completed % 50 === 0 || completed === profiles.length) { console.log( `[syncMissingEmpTypeByMonth] Progress: ${completed}/${profiles.length} profiles processed`, ); } } result.executionTime = `${((Date.now() - startTime) / 1000).toFixed(2)}s`; console.log( `[syncMissingEmpTypeByMonth] Completed: total=${result.totalProfiles}, checked=${result.profilesChecked}, missing=${result.missingEmpType}, synced=${result.syncSuccess}, failed=${result.syncFailed}, skipped=${result.skipped}, elapsed=${result.executionTime}`, ); } catch (error) { console.error("[syncMissingEmpTypeByMonth] Error:", error); throw error; } return result; } /** * Clear org DNA attributes in Keycloak for given profiles * Sets all org DNA fields to empty strings * * @param profileIds - Array of profile IDs to clear * @param profileType - 'PROFILE' for officers or 'PROFILE_EMPLOYEE' for employees * @returns Object with success/failed counts and details */ async clearOrgDnaAttributes( profileIds: string[], profileType: "PROFILE" | "PROFILE_EMPLOYEE", ): Promise<{ total: number; success: number; failed: number; details: Array<{ profileId: string; status: "success" | "failed"; error?: string }>; }> { const result = { total: profileIds.length, success: 0, failed: 0, details: [] as Array<{ profileId: string; status: "success" | "failed"; error?: string }>, }; for (const profileId of profileIds) { 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) { result.failed++; result.details.push({ profileId, status: "failed", error: "No Keycloak user ID found", }); continue; } // Clear org DNA attributes by setting them to empty strings const clearedAttributes: Record = { orgRootDnaId: [""], orgChild1DnaId: [""], orgChild2DnaId: [""], orgChild3DnaId: [""], orgChild4DnaId: [""], }; const success = await updateUserAttributes(keycloakUserId, clearedAttributes); if (success) { result.success++; result.details.push({ profileId, status: "success", }); console.log(`Cleared org DNA attributes for profile ${profileId} (${profileType})`); } else { result.failed++; result.details.push({ profileId, status: "failed", error: "Failed to update Keycloak attributes", }); } } catch (error: any) { result.failed++; result.details.push({ profileId, status: "failed", error: error.message || "Unknown error", }); console.error(`Error clearing org DNA attributes for profile ${profileId}:`, error); } } return result; } /** * Batch sync multiple users with unlimited count and parallel processing * Useful for initial sync or periodic updates * * Features: * - Resume from checkpoint after failures * - Automatic retry with exponential backoff * - Rate limiting to avoid overwhelming Keycloak * - Progress tracking and persistence * * @param options - Optional configuration * @returns Object with success count and details */ async batchSyncUsers(options?: { limit?: number; concurrency?: number; resume?: boolean; // Resume from last checkpoint maxRetries?: number; // Retry attempts for failed operations rateLimit?: number; // Requests per second clearProgress?: boolean; // Start fresh, ignore existing progress }): Promise<{ total: number; success: number; failed: number; details: any[]; resumed?: boolean }> { const limit = options?.limit; const concurrency = options?.concurrency ?? 5; const resume = options?.resume ?? false; const maxRetries = options?.maxRetries ?? 3; const rateLimit = options?.rateLimit ?? 10; const clearProgress = options?.clearProgress ?? false; const result = { total: 0, success: 0, failed: 0, details: [] as any[], resumed: false, }; let progressState: SyncProgressState | null = null; let rateLimiter: RateLimiter | null = null; try { // Handle progress file based on options if (clearProgress) { SyncProgressManager.clear(); console.log("[batchSyncUsers] Cleared existing progress, starting fresh"); } // Load existing progress if resume is requested if (resume && !clearProgress) { progressState = SyncProgressManager.load(); if (progressState) { result.resumed = true; console.log( `[batchSyncUsers] Resuming from checkpoint: ${progressState.lastSyncedIndex}/${progressState.totalProfiles}`, ); SyncProgressManager.logProgress(progressState); } else { console.log("[batchSyncUsers] No existing progress found, starting fresh"); } } // 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; // Initialize or resume progress state if (!progressState) { const profileIds = allProfiles.map((p) => p.profile.id); progressState = SyncProgressManager.initialize(profileIds); SyncProgressManager.save(progressState); console.log(`[batchSyncUsers] Starting sync of ${profileIds.length} profiles`); } // Initialize rate limiter if rate limiting is enabled if (rateLimit && rateLimit > 0) { rateLimiter = new RateLimiter(rateLimit); console.log(`[batchSyncUsers] Rate limiting enabled: ${rateLimit} requests/second`); } // Determine starting index based on progress let startIndex = progressState.lastSyncedIndex; // Process in parallel with concurrency limit const processedResults = await this.processInParallelWithProgress( allProfiles, concurrency, startIndex, async ({ profile, type }, index) => { // Apply rate limiting if enabled if (rateLimiter) { await rateLimiter.throttle(); } const keycloakUserId = profile.keycloak; try { // Wrap sync operation with retry logic const success = await withRetry( async () => this.syncOnOrganizationChange(profile.id, type), maxRetries, 1000, // Base delay: 1 second ); if (success) { result.success++; return { profileId: profile.id, keycloakUserId, status: "success", }; } else { result.failed++; // Add to failed profiles in progress state SyncProgressManager.addFailedProfile( progressState!, index, profile.id, "Sync returned false", ); return { profileId: profile.id, keycloakUserId, status: "failed", error: "Sync returned false", }; } } catch (error: any) { result.failed++; // Add to failed profiles in progress state SyncProgressManager.addFailedProfile( progressState!, index, profile.id, error.message || String(error), ); return { profileId: profile.id, keycloakUserId, status: "error", error: error.message || String(error), }; } }, progressState, (updatedState) => { // Save progress after each batch SyncProgressManager.save(updatedState); // Log progress every 50 items if (updatedState.lastSyncedIndex % 50 === 0 || updatedState.lastSyncedIndex === updatedState.totalProfiles) { SyncProgressManager.logProgress(updatedState); } }, ); // 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); } } // Clear progress on successful completion SyncProgressManager.clear(); const elapsed = SyncProgressManager.formatElapsedTime(progressState.startTime); console.log( `[batchSyncUsers] Completed: total=${result.total}, success=${result.success}, failed=${result.failed}, elapsed=${elapsed}`, ); // Log failed profiles summary if (progressState.failedProfiles.length > 0) { console.log( `[batchSyncUsers] Failed profiles (${progressState.failedProfiles.length}):`, progressState.failedProfiles.map((f) => `${f.profileId}(${f.error})`).join(", "), ); } } catch (error) { console.error("[batchSyncUsers] Error in batch sync:", error); // Save progress before throwing if (progressState) { SyncProgressManager.save(progressState); console.log("[batchSyncUsers] Progress saved. Use resume=true to continue."); } throw error; } return result; } /** * Process items in parallel with concurrency limit and progress tracking * Extends processInParallel with progress state management */ private async processInParallelWithProgress( items: T[], concurrencyLimit: number, startIndex: number, processor: (item: T, index: number) => Promise, progressState: SyncProgressState, onProgress?: (state: SyncProgressState) => void, ): Promise> { const results: Array = []; // Start from the saved checkpoint index for (let i = startIndex; 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) => { const actualIndex = i + batchIndex; try { return await processor(item, actualIndex); } catch (error) { return { error }; } }), ); results.push(...batchResults); // Update progress state progressState.lastSyncedIndex = Math.min(i + concurrencyLimit, items.length); // Call progress callback if (onProgress) { onProgress(progressState); } } return results; } /** * 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 createUserHaveProfile( profile.citizenId, "P@ssw0rd", profile.id, profile.prefix, { 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; } }