hrms-api-org/src/services/KeycloakAttributeService.ts
waruneeauy 2417c90dc2
All checks were successful
Build & Deploy on Dev / build (push) Successful in 1m4s
add api sync-missing-emptype
2026-04-28 11:38:47 +07:00

1521 lines
50 KiB
TypeScript

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<UserProfileAttributes> {
// 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<UserProfileAttributes> {
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<boolean> {
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<string, string[]> = {
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<boolean> {
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<string, string[]> = {
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<T, R>(
items: T[],
concurrencyLimit: number,
startIndex: number,
processor: (item: T, index: number) => Promise<R>,
progressState: SyncProgressState,
onProgress?: (state: SyncProgressState) => void,
): Promise<Array<R | { error: any }>> {
const results: Array<R | { error: any }> = [];
// 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<UserProfileAttributes | null> {
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<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;
}
}