completed sync script update keycloak

This commit is contained in:
Warunee Tamkoo 2026-02-26 21:25:07 +07:00
parent a487b73c3b
commit 625885973e
13 changed files with 1867 additions and 466 deletions

View file

@ -4,7 +4,16 @@ import { ProfileEmployee } from "../entities/ProfileEmployee";
// import { PosMaster } from "../entities/PosMaster";
// import { EmployeePosMaster } from "../entities/EmployeePosMaster";
// import { OrgRoot } from "../entities/OrgRoot";
import { getUser, updateUserAttributes } from "../keycloak";
import {
createUser,
getUser,
getUserByUsername,
updateUserAttributes,
deleteUser,
getRoles,
addUserRoles,
getAllUsersPaginated,
} from "../keycloak";
import { OrgRevision } from "../entities/OrgRevision";
export interface UserProfileAttributes {
@ -15,6 +24,7 @@ export interface UserProfileAttributes {
orgChild3DnaId: string | null;
orgChild4DnaId: string | null;
empType: string | null;
prefix?: string | null;
}
/**
@ -73,6 +83,7 @@ export class KeycloakAttributeService {
orgChild3DnaId,
orgChild4DnaId,
empType: "OFFICER",
prefix: profileResult.prefix,
};
}
@ -107,6 +118,7 @@ export class KeycloakAttributeService {
orgChild3DnaId,
orgChild4DnaId,
empType: profileEmployeeResult.employeeClass,
prefix: profileEmployeeResult.prefix,
};
}
@ -119,6 +131,7 @@ export class KeycloakAttributeService {
orgChild3DnaId: null,
orgChild4DnaId: null,
empType: null,
prefix: null,
};
}
@ -170,6 +183,7 @@ export class KeycloakAttributeService {
orgChild3DnaId,
orgChild4DnaId,
empType: "OFFICER",
prefix: profileResult.prefix,
};
}
} else {
@ -204,6 +218,7 @@ export class KeycloakAttributeService {
orgChild3DnaId,
orgChild4DnaId,
empType: profileEmployeeResult.employeeClass,
prefix: profileEmployeeResult.prefix,
};
}
}
@ -216,6 +231,7 @@ export class KeycloakAttributeService {
orgChild3DnaId: null,
orgChild4DnaId: null,
empType: null,
prefix: null,
};
}
@ -243,6 +259,7 @@ export class KeycloakAttributeService {
orgChild3DnaId: [attributes.orgChild3DnaId || ""],
orgChild4DnaId: [attributes.orgChild4DnaId || ""],
empType: [attributes.empType || ""],
prefix: [attributes.prefix || ""],
};
const success = await updateUserAttributes(keycloakUserId, keycloakAttributes);
@ -297,15 +314,19 @@ export class KeycloakAttributeService {
}
/**
* Batch sync multiple users
* Batch sync multiple users with unlimited count and parallel processing
* Useful for initial sync or periodic updates
*
* @param limit - Maximum number of users to sync (default: 100)
* @param options - Optional configuration (limit for testing, concurrency for parallel processing)
* @returns Object with success count and details
*/
async batchSyncUsers(
limit: number = 100,
): Promise<{ total: number; success: number; failed: number; details: any[] }> {
async batchSyncUsers(options?: {
limit?: number;
concurrency?: number;
}): Promise<{ total: number; success: number; failed: number; details: any[] }> {
const limit = options?.limit;
const concurrency = options?.concurrency ?? 5;
const result = {
total: 0,
success: 0,
@ -314,57 +335,92 @@ export class KeycloakAttributeService {
};
try {
// Get profiles with keycloak IDs (ข้าราชการ)
const profiles = await this.profileRepo
// Build query for profiles with keycloak IDs (ข้าราชการ)
const profileQuery = this.profileRepo
.createQueryBuilder("p")
.where("p.keycloak IS NOT NULL")
.andWhere("p.keycloak != :empty", { empty: "" })
.take(limit)
.getMany();
.andWhere("p.keycloak != :empty", { empty: "" });
// Get profileEmployees with keycloak IDs (ลูกจ้าง)
const profileEmployees = await this.profileEmployeeRepo
// Build query for profileEmployees with keycloak IDs (ลูกจ้าง)
const profileEmployeeQuery = this.profileEmployeeRepo
.createQueryBuilder("pe")
.where("pe.keycloak IS NOT NULL")
.andWhere("pe.keycloak != :empty", { empty: "" })
.take(limit)
.getMany();
.andWhere("pe.keycloak != :empty", { empty: "" });
// Apply limit if specified (for testing purposes)
if (limit !== undefined) {
profileQuery.take(limit);
profileEmployeeQuery.take(limit);
}
// Get profiles from both tables
const [profiles, profileEmployees] = await Promise.all([
profileQuery.getMany(),
profileEmployeeQuery.getMany(),
]);
const allProfiles = [
...profiles.map((p) => ({ profile: p, type: "PROFILE" as const })),
...profileEmployees.map((p) => ({ profile: p, type: "PROFILE_EMPLOYEE" as const })),
];
const allProfiles = [...profiles, ...profileEmployees];
result.total = allProfiles.length;
for (const profile of allProfiles) {
const keycloakUserId = profile.keycloak;
const profileType = profile instanceof Profile ? "PROFILE" : "PROFILE_EMPLOYEE";
// Process in parallel with concurrency limit
const processedResults = await this.processInParallel(
allProfiles,
concurrency,
async ({ profile, type }, _index) => {
const keycloakUserId = profile.keycloak;
try {
const success = await this.syncOnOrganizationChange(profile.id, profileType);
if (success) {
result.success++;
result.details.push({
profileId: profile.id,
keycloakUserId,
status: "success",
});
} else {
try {
const success = await this.syncOnOrganizationChange(profile.id, type);
if (success) {
result.success++;
return {
profileId: profile.id,
keycloakUserId,
status: "success",
};
} else {
result.failed++;
return {
profileId: profile.id,
keycloakUserId,
status: "failed",
error: "Sync returned false",
};
}
} catch (error: any) {
result.failed++;
result.details.push({
return {
profileId: profile.id,
keycloakUserId,
status: "failed",
error: "Sync returned false",
});
status: "error",
error: error.message,
};
}
} catch (error: any) {
},
);
// Separate results from errors
for (const resultItem of processedResults) {
if ("error" in resultItem) {
result.failed++;
result.details.push({
profileId: profile.id,
keycloakUserId,
profileId: "unknown",
keycloakUserId: "unknown",
status: "error",
error: error.message,
error: JSON.stringify(resultItem.error),
});
} else {
result.details.push(resultItem);
}
}
console.log(
`Batch sync completed: total=${result.total}, success=${result.success}, failed=${result.failed}`,
);
} catch (error) {
console.error("Error in batch sync:", error);
}
@ -396,10 +452,477 @@ export class KeycloakAttributeService {
orgChild3DnaId: user.attributes.orgChild3DnaId?.[0] || "",
orgChild4DnaId: user.attributes.orgChild4DnaId?.[0] || "",
empType: user.attributes.empType?.[0] || "",
prefix: user.attributes.prefix?.[0] || "",
};
} catch (error) {
console.error(`Error getting Keycloak attributes for user ${keycloakUserId}:`, error);
return null;
}
}
/**
* Ensure Keycloak user exists for a profile
* Creates user if keycloak field is empty OR if stored keycloak ID doesn't exist in Keycloak
*
* @param profileId - Profile ID
* @param profileType - 'PROFILE' or 'PROFILE_EMPLOYEE'
* @returns Object with status and details
*/
async ensureKeycloakUser(
profileId: string,
profileType: "PROFILE" | "PROFILE_EMPLOYEE",
): Promise<{
success: boolean;
action: "created" | "verified" | "skipped" | "error";
keycloakUserId?: string;
error?: string;
}> {
try {
// Get profile from database
let profile: Profile | ProfileEmployee | null = null;
if (profileType === "PROFILE") {
profile = await this.profileRepo.findOne({ where: { id: profileId } });
} else {
profile = await this.profileEmployeeRepo.findOne({ where: { id: profileId } });
}
if (!profile) {
return {
success: false,
action: "error",
error: `Profile ${profileId} not found in database`,
};
}
// Check if citizenId exists
if (!profile.citizenId) {
return {
success: false,
action: "skipped",
error: "No citizenId found",
};
}
// Case 1: keycloak field is empty -> create new user
if (!profile.keycloak || profile.keycloak.trim() === "") {
const result = await this.createKeycloakUserFromProfile(profile, profileType);
return result;
}
// Case 2: keycloak field is not empty -> verify user exists in Keycloak
const existingUser = await getUser(profile.keycloak);
if (!existingUser) {
// User doesn't exist in Keycloak, create new one
console.log(
`Keycloak user ${profile.keycloak} not found in Keycloak, creating new user for profile ${profileId}`,
);
const result = await this.createKeycloakUserFromProfile(profile, profileType);
return result;
}
// User exists in Keycloak, verified
return {
success: true,
action: "verified",
keycloakUserId: profile.keycloak,
};
} catch (error: any) {
console.error(`Error ensuring Keycloak user for profile ${profileId}:`, error);
return {
success: false,
action: "error",
error: error.message || "Unknown error",
};
}
}
/**
* Create Keycloak user from profile data
*
* @param profile - Profile or ProfileEmployee entity
* @param profileType - 'PROFILE' or 'PROFILE_EMPLOYEE'
* @returns Object with status and details
*/
private async createKeycloakUserFromProfile(
profile: Profile | ProfileEmployee,
profileType: "PROFILE" | "PROFILE_EMPLOYEE",
): Promise<{
success: boolean;
action: "created" | "verified" | "skipped" | "error";
keycloakUserId?: string;
error?: string;
}> {
try {
// Check if user already exists by username (citizenId)
const existingUserByUsername = await getUserByUsername(profile.citizenId);
if (Array.isArray(existingUserByUsername) && existingUserByUsername.length > 0) {
// User already exists with this username, update the keycloak field
const existingUserId = existingUserByUsername[0].id;
console.log(
`User with citizenId ${profile.citizenId} already exists in Keycloak with ID ${existingUserId}`,
);
// Update the keycloak field in database
if (profileType === "PROFILE") {
await this.profileRepo.update(profile.id, { keycloak: existingUserId });
} else {
await this.profileEmployeeRepo.update(profile.id, { keycloak: existingUserId });
}
// Assign default USER role to existing user
const userRole = await getRoles("USER");
if (userRole && typeof userRole === "object" && "id" in userRole && "name" in userRole) {
const roleAssigned = await addUserRoles(existingUserId, [
{ id: String(userRole.id), name: String(userRole.name) },
]);
if (roleAssigned) {
console.log(`Assigned USER role to existing user ${existingUserId}`);
} else {
console.warn(`Failed to assign USER role to existing user ${existingUserId}`);
}
} else {
console.warn(`USER role not found in Keycloak`);
}
return {
success: true,
action: "verified",
keycloakUserId: existingUserId,
};
}
// Create new user in Keycloak
const createResult = await createUser(profile.citizenId, "P@ssw0rd", {
firstName: profile.firstName || "",
lastName: profile.lastName || "",
email: profile.email || undefined,
enabled: true,
});
if (!createResult || typeof createResult !== "string") {
return {
success: false,
action: "error",
error: "Failed to create user in Keycloak",
};
}
const keycloakUserId = createResult;
// Update the keycloak field in database
if (profileType === "PROFILE") {
await this.profileRepo.update(profile.id, { keycloak: keycloakUserId });
} else {
await this.profileEmployeeRepo.update(profile.id, { keycloak: keycloakUserId });
}
// Assign default USER role
const userRole = await getRoles("USER");
if (userRole && typeof userRole === "object" && "id" in userRole && "name" in userRole) {
const roleAssigned = await addUserRoles(keycloakUserId, [
{ id: String(userRole.id), name: String(userRole.name) },
]);
if (roleAssigned) {
console.log(`Assigned USER role to user ${keycloakUserId}`);
} else {
console.warn(`Failed to assign USER role to user ${keycloakUserId}`);
}
} else {
console.warn(`USER role not found in Keycloak`);
}
console.log(
`Created Keycloak user for profile ${profile.id} (citizenId: ${profile.citizenId}) with ID ${keycloakUserId}`,
);
return {
success: true,
action: "created",
keycloakUserId,
};
} catch (error: any) {
console.error(`Error creating Keycloak user for profile ${profile.id}:`, error);
return {
success: false,
action: "error",
error: error.message || "Unknown error",
};
}
}
/**
* Process items in parallel with concurrency limit
*/
private async processInParallel<T, R>(
items: T[],
concurrencyLimit: number,
processor: (item: T, index: number) => Promise<R>,
): Promise<Array<R | { error: any }>> {
const results: Array<R | { error: any }> = [];
// Process items in batches
for (let i = 0; i < items.length; i += concurrencyLimit) {
const batch = items.slice(i, i + concurrencyLimit);
// Process batch in parallel with error handling
const batchResults = await Promise.all(
batch.map(async (item, batchIndex) => {
try {
return await processor(item, i + batchIndex);
} catch (error) {
return { error };
}
}),
);
results.push(...batchResults);
// Log progress after each batch
const completed = Math.min(i + concurrencyLimit, items.length);
console.log(`Progress: ${completed}/${items.length}`);
}
return results;
}
/**
* Batch ensure Keycloak users for all profiles
* Processes all rows in Profile and ProfileEmployee tables
*
* @returns Object with total, success, failed counts and details
*/
async batchEnsureKeycloakUsers(): Promise<{
total: number;
created: number;
verified: number;
skipped: number;
failed: number;
details: Array<{
profileId: string;
profileType: string;
action: string;
keycloakUserId?: string;
error?: string;
}>;
}> {
const result = {
total: 0,
created: 0,
verified: 0,
skipped: 0,
failed: 0,
details: [] as Array<{
profileId: string;
profileType: string;
action: string;
keycloakUserId?: string;
error?: string;
}>,
};
try {
// Get all profiles from Profile table (ข้าราชการ)
const profiles = await this.profileRepo.find({ where: { isLeave: false } }); // Only active profiles
// Get all profiles from ProfileEmployee table (ลูกจ้าง)
const profileEmployees = await this.profileEmployeeRepo.find({ where: { isLeave: false } }); // Only active profiles
const allProfiles = [
...profiles.map((p) => ({ profile: p, type: "PROFILE" as const })),
...profileEmployees.map((p) => ({ profile: p, type: "PROFILE_EMPLOYEE" as const })),
];
result.total = allProfiles.length;
// Process in parallel with concurrency limit
const CONCURRENCY_LIMIT = 5; // Adjust based on environment
const processedResults = await this.processInParallel(
allProfiles,
CONCURRENCY_LIMIT,
async ({ profile, type }) => {
const ensureResult = await this.ensureKeycloakUser(profile.id, type);
// Update counters
switch (ensureResult.action) {
case "created":
result.created++;
break;
case "verified":
result.verified++;
break;
case "skipped":
result.skipped++;
break;
case "error":
result.failed++;
break;
}
return {
profileId: profile.id,
profileType: type,
action: ensureResult.action,
keycloakUserId: ensureResult.keycloakUserId,
error: ensureResult.error,
};
},
);
// Separate results from errors
for (const resultItem of processedResults) {
if ("error" in resultItem) {
result.failed++;
result.details.push({
profileId: "unknown",
profileType: "unknown",
action: "error",
error: JSON.stringify(resultItem.error),
});
} else {
result.details.push(resultItem);
}
}
console.log(
`Batch ensure Keycloak users completed: total=${result.total}, created=${result.created}, verified=${result.verified}, skipped=${result.skipped}, failed=${result.failed}`,
);
} catch (error) {
console.error("Error in batch ensure Keycloak users:", error);
}
return result;
}
/**
* Clear orphaned Keycloak users
* Deletes users in Keycloak that are not referenced in Profile or ProfileEmployee tables
*
* @param skipUsernames - Array of usernames to skip (e.g., ['super_admin'])
* @returns Object with counts and details
*/
async clearOrphanedKeycloakUsers(skipUsernames: string[] = []): Promise<{
totalInKeycloak: number;
totalInDatabase: number;
orphanedCount: number;
deleted: number;
skipped: number;
failed: number;
details: Array<{
keycloakUserId: string;
username: string;
action: "deleted" | "skipped" | "error";
error?: string;
}>;
}> {
const result = {
totalInKeycloak: 0,
totalInDatabase: 0,
orphanedCount: 0,
deleted: 0,
skipped: 0,
failed: 0,
details: [] as Array<{
keycloakUserId: string;
username: string;
action: "deleted" | "skipped" | "error";
error?: string;
}>,
};
try {
// Get all keycloak IDs from database (Profile + ProfileEmployee)
const profiles = await this.profileRepo
.createQueryBuilder("p")
.where("p.keycloak IS NOT NULL")
.andWhere("p.keycloak != :empty", { empty: "" })
.getMany();
const profileEmployees = await this.profileEmployeeRepo
.createQueryBuilder("pe")
.where("pe.keycloak IS NOT NULL")
.andWhere("pe.keycloak != :empty", { empty: "" })
.getMany();
// Create a Set of all keycloak IDs in database for O(1) lookup
const databaseKeycloakIds = new Set<string>();
for (const p of profiles) {
if (p.keycloak) databaseKeycloakIds.add(p.keycloak);
}
for (const pe of profileEmployees) {
if (pe.keycloak) databaseKeycloakIds.add(pe.keycloak);
}
result.totalInDatabase = databaseKeycloakIds.size;
// Get all users from Keycloak with pagination to avoid response size limits
const keycloakUsers = await getAllUsersPaginated();
if (!keycloakUsers || typeof keycloakUsers !== "object") {
throw new Error("Failed to get users from Keycloak");
}
result.totalInKeycloak = keycloakUsers.length;
// Find orphaned users (in Keycloak but not in database)
const orphanedUsers = keycloakUsers.filter((user: any) => !databaseKeycloakIds.has(user.id));
result.orphanedCount = orphanedUsers.length;
// Delete orphaned users (skip protected ones)
for (const user of orphanedUsers) {
const username = user.username;
const userId = user.id;
// Check if user should be skipped
if (skipUsernames.includes(username)) {
result.skipped++;
result.details.push({
keycloakUserId: userId,
username,
action: "skipped",
});
continue;
}
// Delete user from Keycloak
try {
const deleteSuccess = await deleteUser(userId);
if (deleteSuccess) {
result.deleted++;
result.details.push({
keycloakUserId: userId,
username,
action: "deleted",
});
} else {
result.failed++;
result.details.push({
keycloakUserId: userId,
username,
action: "error",
error: "Failed to delete user from Keycloak",
});
}
} catch (error: any) {
result.failed++;
result.details.push({
keycloakUserId: userId,
username,
action: "error",
error: error.message || "Unknown error",
});
}
}
console.log(
`Clear orphaned Keycloak users completed: totalInKeycloak=${result.totalInKeycloak}, totalInDatabase=${result.totalInDatabase}, orphaned=${result.orphanedCount}, deleted=${result.deleted}, skipped=${result.skipped}, failed=${result.failed}`,
);
} catch (error) {
console.error("Error in clear orphaned Keycloak users:", error);
throw error;
}
return result;
}
}