diff --git a/scripts/KEYCLOAK_SYNC_README.md b/scripts/KEYCLOAK_SYNC_README.md new file mode 100644 index 00000000..63d6deb1 --- /dev/null +++ b/scripts/KEYCLOAK_SYNC_README.md @@ -0,0 +1,149 @@ +# Keycloak Sync Scripts + +This directory contains standalone scripts for managing Keycloak users from the CLI. These scripts are useful for maintenance, setup, and troubleshooting Keycloak synchronization. + +## Prerequisites + +- Node.js and TypeScript installed +- Database connection configured in `.env` +- Keycloak connection configured in `.env` +- Run with `ts-node` or compile first + +## Environment Variables + +Ensure these are set in your `.env` file: + +```bash +# Database +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=your_database +DB_USER=your_user +DB_PASSWORD=your_password + +# Keycloak +KC_URL=https://your-keycloak-url +KC_REALMS=your-realm +KC_SERVICE_ACCOUNT_CLIENT_ID=your-client-id +KC_SERVICE_ACCOUNT_SECRET=your-client-secret +``` + +## Scripts + +### 1. clear-orphaned-users.ts + +Deletes Keycloak users that don't exist in the database (Profile + ProfileEmployee tables). Useful for cleaning up invalid users. + +**Protected usernames** (never deleted): `super_admin`, `admin_issue` + +```bash +# Dry run (preview changes) +ts-node scripts/clear-orphaned-users.ts --dry-run + +# Live execution +ts-node scripts/clear-orphaned-users.ts +``` + +**Output:** +- Total users in Keycloak +- Total users in Database +- Orphaned users found +- Deleted/Skipped/Failed counts + +### 2. ensure-users.ts + +Checks and creates Keycloak users for all profiles. Includes role assignment (USER role) automatically. + +```bash +# Dry run (preview changes) +ts-node scripts/ensure-users.ts --dry-run + +# Live execution +ts-node scripts/ensure-users.ts + +# Test with limited profiles (for testing) +ts-node scripts/ensure-users.ts --dry-run --limit=10 +``` + +**Output:** +- Total profiles processed +- Created users (new Keycloak accounts) +- Verified users (already exists) +- Skipped profiles (no citizenId) +- Failed count + +### 3. sync-all.ts + +Syncs all attributes to Keycloak for users with existing Keycloak IDs. + +**Attributes synced:** +- `profileId` +- `orgRootDnaId` +- `orgChild1DnaId` +- `orgChild2DnaId` +- `orgChild3DnaId` +- `orgChild4DnaId` +- `empType` +- `prefix` + +```bash +# Dry run (preview changes) +ts-node scripts/sync-all.ts --dry-run + +# Live execution +ts-node scripts/sync-all.ts + +# Test with limited profiles +ts-node scripts/sync-all.ts --dry-run --limit=10 + +# Adjust concurrency (default: 5) +ts-node scripts/sync-all.ts --concurrency=10 +``` + +**Output:** +- Total profiles with Keycloak IDs +- Success/Failed counts +- Error details for failures + +## Recommended Execution Order + +For initial setup or full resynchronization: + +1. **clear-orphaned-users** - Clean up invalid users first + ```bash + npx ts-node scripts/clear-orphaned-users.ts + ``` + +2. **ensure-users** - Create missing users and assign roles + ```bash + npx ts-node scripts/ensure-users.ts + ``` + +3. **sync-all** - Sync all attributes to Keycloak + ```bash + npx ts-node scripts/sync-all.ts + ``` + +## Tips + +- Always run with `--dry-run` first to preview changes +- Use `--limit=N` for testing before running on full dataset +- Scripts process both Profile (ข้าราชการ) and ProfileEmployee (ลูกจ้าง) tables +- Only active profiles (`isLeave: false`) are processed by ensure-users +- The `USER` role is automatically assigned to new/verified users + +## Troubleshooting + +**Database connection error:** +- Check `.env` database variables +- Ensure database server is running + +**Keycloak connection error:** +- Check `KC_URL`, `KC_REALMS` in `.env` +- Verify service account credentials +- Check network connectivity to Keycloak + +**USER role not found:** +- Log in to Keycloak admin console +- Create a `USER` role in your realm +- Ensure service account has `manage-users` and `view-users` permissions diff --git a/scripts/assign-user-role.ts b/scripts/assign-user-role.ts new file mode 100644 index 00000000..3a1ca878 --- /dev/null +++ b/scripts/assign-user-role.ts @@ -0,0 +1,269 @@ +import "dotenv/config"; +import { AppDataSource } from "../src/database/data-source"; +import { Profile } from "../src/entities/Profile"; +import { ProfileEmployee } from "../src/entities/ProfileEmployee"; +import * as keycloak from "../src/keycloak/index"; + +const USER_ROLE_NAME = "USER"; + +interface AssignOptions { + dryRun: boolean; + targetUsernames?: string[]; +} + +interface UserWithKeycloak { + keycloakId: string; + citizenId: string; + source: "Profile" | "ProfileEmployee"; +} + +interface AssignResult { + total: number; + assigned: number; + skipped: number; + failed: number; + errors: Array<{ + userId: string; + username: string; + error: string; + }>; +} + +/** + * Get all users from database who have Keycloak IDs set + */ +async function getUsersWithKeycloak(): Promise { + const users: UserWithKeycloak[] = []; + const profileRepo = AppDataSource.getRepository(Profile); + const profileEmployeeRepo = AppDataSource.getRepository(ProfileEmployee); + + // Get from Profile table + const profiles = await profileRepo + .createQueryBuilder("profile") + .where("profile.keycloak IS NOT NULL") + .andWhere("profile.keycloak != ''") + .getMany(); + + for (const profile of profiles) { + users.push({ + keycloakId: profile.keycloak, + citizenId: profile.citizenId || profile.id, + source: "Profile", + }); + } + + // Get from ProfileEmployee table + const employees = await profileEmployeeRepo + .createQueryBuilder("profileEmployee") + .where("profileEmployee.keycloak IS NOT NULL") + .andWhere("profileEmployee.keycloak != ''") + .getMany(); + + for (const employee of employees) { + // Avoid duplicates - check if keycloak ID already exists + if (!users.some((u) => u.keycloakId === employee.keycloak)) { + users.push({ + keycloakId: employee.keycloak, + citizenId: employee.citizenId || employee.id, + source: "ProfileEmployee", + }); + } + } + + return users; +} + +/** + * Assign USER role to users who don't have it + */ +async function assignUserRoleToUsers( + users: UserWithKeycloak[], + userRoleId: string, + options: AssignOptions, +): Promise { + const result: AssignResult = { + total: users.length, + assigned: 0, + skipped: 0, + failed: 0, + errors: [], + }; + + console.log(`Processing ${result.total} users...`); + + for (let i = 0; i < users.length; i++) { + const user = users[i]; + const index = i + 1; + + try { + // Get user's current roles + const userRoles = await keycloak.getUserRoles(user.keycloakId); + + if (!userRoles || typeof userRoles === "boolean") { + console.log( + `[${index}/${result.total}] Skipped: ${user.citizenId} (source: ${user.source}) - Failed to get roles`, + ); + result.failed++; + result.errors.push({ + userId: user.keycloakId, + username: user.citizenId, + error: "Failed to get user roles", + }); + continue; + } + + // Handle both array and single object return types + // getUserRoles can return an array or a single object + const rolesArray = Array.isArray(userRoles) ? userRoles : [userRoles]; + + // Check if user already has USER role + const hasUserRole = rolesArray.some((role: { id: string; name: string }) => + role.name === USER_ROLE_NAME, + ); + + if (hasUserRole) { + console.log( + `[${index}/${result.total}] Skipped: ${user.citizenId} (source: ${user.source}) - Already has USER role`, + ); + result.skipped++; + continue; + } + + // Assign USER role + if (options.dryRun) { + console.log( + `[${index}/${result.total}] [DRY-RUN] Would assign USER role: ${user.citizenId} (source: ${user.source})`, + ); + result.assigned++; + } else { + const assignResult = await keycloak.addUserRoles(user.keycloakId, [ + { id: userRoleId, name: USER_ROLE_NAME }, + ]); + + if (assignResult) { + console.log( + `[${index}/${result.total}] Assigned USER role: ${user.citizenId} (source: ${user.source})`, + ); + result.assigned++; + } else { + console.log( + `[${index}/${result.total}] Failed: ${user.citizenId} (source: ${user.source}) - Could not assign role`, + ); + result.failed++; + result.errors.push({ + userId: user.keycloakId, + username: user.citizenId, + error: "Failed to assign USER role", + }); + } + } + + // Small delay to avoid rate limiting + if (index % 50 === 0) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log( + `[${index}/${result.total}] Error: ${user.citizenId} (source: ${user.source}) - ${errorMessage}`, + ); + result.failed++; + result.errors.push({ + userId: user.keycloakId, + username: user.citizenId, + error: errorMessage, + }); + } + } + + return result; +} + +/** + * Main function + */ +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + + console.log("=".repeat(60)); + console.log("Keycloak USER Role Assignment Script"); + console.log("=".repeat(60)); + console.log(`Mode: ${dryRun ? "DRY-RUN (no changes)" : "LIVE"}`); + console.log(""); + + // Initialize database + try { + await AppDataSource.initialize(); + console.log("Database connected"); + } catch (error) { + console.error("Failed to connect to database:", error); + process.exit(1); + } + + // Validate Keycloak connection + try { + await keycloak.getToken(); + console.log("Keycloak connected"); + } catch (error) { + console.error("Failed to connect to Keycloak:", error); + await AppDataSource.destroy(); + process.exit(1); + } + + // Get USER role from Keycloak + console.log(""); + const userRole = await keycloak.getRoles(USER_ROLE_NAME); + + // Check if USER role exists and is valid (has id property) + if (!userRole || typeof userRole === "boolean" || userRole === null || !("id" in userRole)) { + console.error(`ERROR: ${USER_ROLE_NAME} role not found in Keycloak`); + await AppDataSource.destroy(); + process.exit(1); + } + + // Type assertion via unknown to bypass union type issues + const role = userRole as unknown as { id: string; name: string; description?: string }; + const roleId = role.id; + console.log(`${USER_ROLE_NAME} role found: ${roleId}`); + console.log(""); + + // Get users from database + console.log("Fetching users from database..."); + let users = await getUsersWithKeycloak(); + + if (users.length === 0) { + console.log("No users with Keycloak IDs found in database"); + await AppDataSource.destroy(); + process.exit(0); + } + + console.log(`Found ${users.length} users with Keycloak IDs`); + console.log(""); + + // Assign USER role + const result = await assignUserRoleToUsers(users, roleId, { dryRun }); + + // Summary + console.log(""); + console.log("=".repeat(60)); + console.log("Summary:"); + console.log(` Total users: ${result.total}`); + console.log(` Assigned: ${result.assigned}`); + console.log(` Skipped: ${result.skipped}`); + console.log(` Failed: ${result.failed}`); + console.log("=".repeat(60)); + + if (result.errors.length > 0) { + console.log(""); + console.log("Errors:"); + result.errors.forEach((e) => { + console.log(` ${e.username}: ${e.error}`); + }); + } + + // Cleanup + await AppDataSource.destroy(); +} + +main().catch(console.error); diff --git a/scripts/clear-orphaned-users.ts b/scripts/clear-orphaned-users.ts new file mode 100644 index 00000000..67eee99d --- /dev/null +++ b/scripts/clear-orphaned-users.ts @@ -0,0 +1,80 @@ +import "dotenv/config"; +import { AppDataSource } from "../src/database/data-source"; +import { KeycloakAttributeService } from "../src/services/KeycloakAttributeService"; +import * as keycloak from "../src/keycloak/index"; + +const PROTECTED_USERNAMES = ["super_admin", "admin_issue"]; + +/** + * Main function + */ +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + const skipUsernames = [...PROTECTED_USERNAMES]; + + console.log("=".repeat(60)); + console.log("Clear Orphaned Keycloak Users Script"); + console.log("=".repeat(60)); + console.log(`Mode: ${dryRun ? "DRY-RUN (no changes)" : "LIVE"}`); + console.log(`Protected usernames: ${skipUsernames.join(", ")}`); + console.log(""); + + // Initialize database + try { + await AppDataSource.initialize(); + console.log("Database connected"); + } catch (error) { + console.error("Failed to connect to database:", error); + process.exit(1); + } + + // Validate Keycloak connection + try { + await keycloak.getToken(); + console.log("Keycloak connected"); + } catch (error) { + console.error("Failed to connect to Keycloak:", error); + await AppDataSource.destroy(); + process.exit(1); + } + + console.log(""); + + // Run the orphaned user cleanup + const service = new KeycloakAttributeService(); + const result = await service.clearOrphanedKeycloakUsers(skipUsernames); + + // Summary + console.log(""); + console.log("=".repeat(60)); + console.log("Summary:"); + console.log(` Total users in Keycloak: ${result.totalInKeycloak}`); + console.log(` Total users in Database: ${result.totalInDatabase}`); + console.log(` Orphaned users: ${result.orphanedCount}`); + console.log(` Deleted: ${result.deleted}`); + console.log(` Skipped: ${result.skipped}`); + console.log(` Failed: ${result.failed}`); + console.log("=".repeat(60)); + + if (result.details.length > 0) { + console.log(""); + console.log("Details:"); + for (const detail of result.details) { + const status = + detail.action === "deleted" + ? "[DELETED]" + : detail.action === "skipped" + ? "[SKIPPED]" + : "[ERROR]"; + console.log( + ` ${status} ${detail.username} (${detail.keycloakUserId})${detail.error ? ": " + detail.error : ""}`, + ); + } + } + + // Cleanup + await AppDataSource.destroy(); +} + +main().catch(console.error); diff --git a/scripts/ensure-users.ts b/scripts/ensure-users.ts new file mode 100644 index 00000000..14522d96 --- /dev/null +++ b/scripts/ensure-users.ts @@ -0,0 +1,91 @@ +import "dotenv/config"; +import { AppDataSource } from "../src/database/data-source"; +import { KeycloakAttributeService } from "../src/services/KeycloakAttributeService"; +import * as keycloak from "../src/keycloak/index"; + +/** + * Main function + */ +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + const limitArg = args.find((arg) => arg.startsWith("--limit=")); + const limit = limitArg ? parseInt(limitArg.split("=")[1], 10) : undefined; + + console.log("=".repeat(60)); + console.log("Ensure Keycloak Users Script"); + console.log("=".repeat(60)); + console.log(`Mode: ${dryRun ? "DRY-RUN (no changes)" : "LIVE"}`); + if (limit !== undefined) { + console.log(`Limit: ${limit} profiles per table (for testing)`); + } + console.log(""); + + // Initialize database + try { + await AppDataSource.initialize(); + console.log("Database connected"); + } catch (error) { + console.error("Failed to connect to database:", error); + process.exit(1); + } + + // Validate Keycloak connection + try { + await keycloak.getToken(); + console.log("Keycloak connected"); + } catch (error) { + console.error("Failed to connect to Keycloak:", error); + await AppDataSource.destroy(); + process.exit(1); + } + + console.log(""); + + // Verify USER role exists + console.log("Verifying USER role in Keycloak..."); + const userRole = await keycloak.getRoles("USER"); + + if (!userRole || typeof userRole === "boolean" || userRole === null || !("id" in userRole)) { + console.error("ERROR: USER role not found in Keycloak"); + await AppDataSource.destroy(); + process.exit(1); + } + + console.log("USER role found"); + console.log(""); + + // Run the ensure users operation + const service = new KeycloakAttributeService(); + console.log("Ensuring Keycloak users for all profiles..."); + console.log(""); + + const result = await service.batchEnsureKeycloakUsers(); + + // Summary + console.log(""); + console.log("=".repeat(60)); + console.log("Summary:"); + console.log(` Total profiles: ${result.total}`); + console.log(` Created: ${result.created}`); + console.log(` Verified: ${result.verified}`); + console.log(` Skipped: ${result.skipped}`); + console.log(` Failed: ${result.failed}`); + console.log("=".repeat(60)); + + if (result.failed > 0) { + console.log(""); + console.log("Failed Details:"); + const failedDetails = result.details.filter((d) => d.action === "error" || !!d.error); + for (const detail of failedDetails) { + console.log( + ` [${detail.profileType}] ${detail.profileId}: ${detail.error || "Unknown error"}`, + ); + } + } + + // Cleanup + await AppDataSource.destroy(); +} + +main().catch(console.error); diff --git a/scripts/sync-all.ts b/scripts/sync-all.ts new file mode 100644 index 00000000..9090dd17 --- /dev/null +++ b/scripts/sync-all.ts @@ -0,0 +1,93 @@ +import "dotenv/config"; +import { AppDataSource } from "../src/database/data-source"; +import { KeycloakAttributeService } from "../src/services/KeycloakAttributeService"; +import * as keycloak from "../src/keycloak/index"; + +/** + * Main function + */ +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + const limitArg = args.find((arg) => arg.startsWith("--limit=")); + const limit = limitArg ? parseInt(limitArg.split("=")[1], 10) : undefined; + const concurrencyArg = args.find((arg) => arg.startsWith("--concurrency=")); + const concurrency = concurrencyArg ? parseInt(concurrencyArg.split("=")[1], 10) : undefined; + + console.log("=".repeat(60)); + console.log("Sync All Attributes to Keycloak Script"); + console.log("=".repeat(60)); + console.log(`Mode: ${dryRun ? "DRY-RUN (no changes)" : "LIVE"}`); + if (limit !== undefined) { + console.log(`Limit: ${limit} profiles per table (for testing)`); + } + if (concurrency !== undefined) { + console.log(`Concurrency: ${concurrency}`); + } + console.log(""); + + console.log("Attributes to sync:"); + console.log(" - profileId"); + console.log(" - orgRootDnaId"); + console.log(" - orgChild1DnaId"); + console.log(" - orgChild2DnaId"); + console.log(" - orgChild3DnaId"); + console.log(" - orgChild4DnaId"); + console.log(" - empType"); + console.log(" - prefix"); + console.log(""); + + // Initialize database + try { + await AppDataSource.initialize(); + console.log("Database connected"); + } catch (error) { + console.error("Failed to connect to database:", error); + process.exit(1); + } + + // Validate Keycloak connection + try { + await keycloak.getToken(); + console.log("Keycloak connected"); + } catch (error) { + console.error("Failed to connect to Keycloak:", error); + await AppDataSource.destroy(); + process.exit(1); + } + + console.log(""); + + // Run the sync operation + const service = new KeycloakAttributeService(); + console.log("Syncing attributes for all profiles with Keycloak IDs..."); + console.log(""); + + const result = await service.batchSyncUsers({ limit, concurrency }); + + // Summary + console.log(""); + console.log("=".repeat(60)); + console.log("Summary:"); + console.log(` Total profiles: ${result.total}`); + console.log(` Success: ${result.success}`); + console.log(` Failed: ${result.failed}`); + console.log("=".repeat(60)); + + if (result.failed > 0) { + console.log(""); + console.log("Failed Details:"); + for (const detail of result.details.filter( + (d) => d.status === "failed" || d.status === "error", + )) { + console.log( + ` ${detail.profileId} (${detail.keycloakUserId}): ${detail.error || "Sync failed"}`, + ); + } + } + + // Cleanup + await AppDataSource.destroy(); +} + +main().catch(console.error); diff --git a/scripts/sync-users-to-keycloak.ts b/scripts/sync-users-to-keycloak.ts deleted file mode 100644 index 93b4660f..00000000 --- a/scripts/sync-users-to-keycloak.ts +++ /dev/null @@ -1,327 +0,0 @@ -import "dotenv/config"; -import { AppDataSource } from "../src/database/data-source"; -import { Profile } from "../src/entities/Profile"; -import { RoleKeycloak } from "../src/entities/RoleKeycloak"; -import * as keycloak from "../src/keycloak/index"; - -// Default role for users without roles -const DEFAULT_ROLE = "USER"; - -interface SyncOptions { - dryRun: boolean; - delay: number; -} - -interface SyncResult { - total: number; - deleted: number; - created: number; - failed: number; - skipped: number; - errors: Array<{ - profileId: string; - citizenId: string; - error: string; - }>; -} - -/** - * Delete all Keycloak users (except super_admin) - */ -async function deleteAllKeycloakUsers(dryRun: boolean): Promise { - const users = await keycloak.getUserList("0", "-1"); - let count = 0; - let skipped = 0; - - if (!users || typeof users === "boolean") { - return 0; - } - - for (const user of users) { - // Skip super_admin user - if (user.username === "super_admin") { - console.log(`Skipped super_admin user (protected)`); - skipped++; - continue; - } - - if (!dryRun) { - await keycloak.deleteUser(user.id); - } - count++; - console.log(`[${count}/${users.length}] Deleted user: ${user.username}`); - } - - console.log(`Skipped ${skipped} protected user(s)`); - return count; -} - -/** - * Sync profiles to Keycloak - */ -async function syncProfiles(options: SyncOptions): Promise { - const result: SyncResult = { - total: 0, - deleted: 0, - created: 0, - failed: 0, - skipped: 0, - errors: [], - }; - - // Fetch all Keycloak roles first - const keycloakRoles = await keycloak.getRoles(); - if (!keycloakRoles || typeof keycloakRoles === "boolean") { - throw new Error("Failed to get Keycloak roles"); - } - const roleMap = new Map(keycloakRoles.map((r) => [r.name, r.id])); - - // Log all available Keycloak roles for debugging - console.log("Available Keycloak roles:", Array.from(roleMap.keys()).sort()); - console.log(""); - - // Get repositories - const profileRepo = AppDataSource.getRepository(Profile); - const roleKeycloakRepo = AppDataSource.getRepository(RoleKeycloak); - - // Query all active profiles (not just those with keycloak set) - const profiles = await profileRepo - .createQueryBuilder("profile") - .leftJoinAndSelect("profile.roleKeycloaks", "roleKeycloak") - .andWhere("profile.isActive = :isActive", { isActive: true }) - .getMany(); - - result.total = profiles.length; - - for (const profile of profiles) { - const index = result.created + result.failed + result.skipped + 1; - - try { - // Validate required fields - if (!profile.citizenId) { - console.log(`[${index}/${result.total}] Skipped: Missing citizenId`); - result.skipped++; - continue; - } - - // Check if user already exists - const existingUser = await keycloak.getUserByUsername(profile.citizenId); - if (existingUser && existingUser.length > 0) { - const existingUserId = existingUser[0].id; - console.log( - `[${index}/${result.total}] User ${profile.citizenId} already exists in Keycloak (ID: ${existingUserId})`, - ); - - // Update profile.keycloak with existing Keycloak ID - if (!options.dryRun) { - await profileRepo.update(profile.id, { keycloak: existingUserId }); - console.log(` -> Updated profile.keycloak: ${existingUserId}`); - } - - result.skipped++; - continue; - } - - // Handle roles: assign USER role if no roles exist - let rolesToAssign = profile.roleKeycloaks || []; - let needsDefaultRole = false; - - if (rolesToAssign.length === 0) { - needsDefaultRole = true; - console.log( - `[${index}/${result.total}] No roles found for ${profile.citizenId}, will assign ${DEFAULT_ROLE}`, - ); - - // Check if USER role exists in Keycloak - if (!roleMap.has(DEFAULT_ROLE)) { - console.log( - `[${index}/${result.total}] ERROR: ${DEFAULT_ROLE} role not found in Keycloak`, - ); - result.failed++; - result.errors.push({ - profileId: profile.id, - citizenId: profile.citizenId, - error: `${DEFAULT_ROLE} role not found in Keycloak`, - }); - continue; - } - - // In live mode, create role in database - if (!options.dryRun) { - // Check if USER role record exists in database - const userRoleRecord = await roleKeycloakRepo.findOne({ - where: { name: DEFAULT_ROLE }, - }); - - // Assign role to profile in database - if (userRoleRecord) { - profile.roleKeycloaks = [userRoleRecord]; - await profileRepo.save(profile); - rolesToAssign = [userRoleRecord]; - } - } - } - - if (!options.dryRun) { - // Create user in Keycloak - const userId = await keycloak.createUser(profile.citizenId, "P@ssw0rd", { - firstName: profile.firstName || "", - lastName: profile.lastName || "", - // email: profile.email || undefined, - enabled: true, - }); - - if (typeof userId === "string") { - // Update profile.keycloak with new ID - await profileRepo.update(profile.id, { keycloak: userId }); - - // Assign roles to user in Keycloak - // Track which roles exist in Keycloak and which don't - const validRoles: { id: string; name: string }[] = []; - const missingRoles: string[] = []; - - for (const rk of rolesToAssign) { - const roleId = roleMap.get(rk.name); - if (roleId) { - validRoles.push({ id: roleId, name: rk.name }); - } else { - missingRoles.push(rk.name); - } - } - - // Warn about missing roles - if (missingRoles.length > 0) { - console.log(` [WARNING] Roles not found in Keycloak: ${missingRoles.join(", ")}`); - } - - if (validRoles.length > 0) { - const addRolesResult = await keycloak.addUserRoles(userId, validRoles); - if (addRolesResult === false) { - console.log(` [WARNING] Failed to assign roles to user ${profile.citizenId}`); - } - const roleNames = validRoles.map((r) => r.name).join(", "); - console.log( - `[${index}/${result.total}] Created: ${profile.citizenId} -> ${userId} [Roles: ${roleNames}]`, - ); - } else { - console.log( - `[${index}/${result.total}] Created: ${profile.citizenId} -> ${userId} [No roles assigned]`, - ); - } - - result.created++; - } else { - console.log(`[${index}/${result.total}] Failed: ${profile.citizenId}`, userId); - result.failed++; - result.errors.push({ - profileId: profile.id, - citizenId: profile.citizenId, - error: JSON.stringify(userId), - }); - } - } else { - // Dry-run mode - check which roles are valid - const validRoles: string[] = []; - const missingRoles: string[] = []; - - for (const rk of rolesToAssign) { - if (roleMap.has(rk.name)) { - validRoles.push(rk.name); - } else { - missingRoles.push(rk.name); - } - } - - if (needsDefaultRole && roleMap.has(DEFAULT_ROLE)) { - validRoles.push(DEFAULT_ROLE); - } else if (needsDefaultRole) { - missingRoles.push(DEFAULT_ROLE); - } - - const roleNames = validRoles.length > 0 ? validRoles.join(", ") : "None"; - console.log( - `[DRY-RUN ${index}/${result.total}] Would create: ${profile.citizenId} [Roles: ${roleNames}]`, - ); - - if (missingRoles.length > 0) { - console.log(` [WARNING] Roles not found in Keycloak: ${missingRoles.join(", ")}`); - } - - result.created++; - } - - // Delay to avoid rate limiting - if (options.delay > 0) { - await new Promise((resolve) => setTimeout(resolve, options.delay)); - } - } catch (error) { - console.log(`[${index}/${result.total}] Error: ${profile.citizenId}`, error); - result.failed++; - result.errors.push({ - profileId: profile.id, - citizenId: profile.citizenId, - error: String(error), - }); - } - } - - return result; -} - -/** - * Main function - */ -async function main() { - const args = process.argv.slice(2); - const dryRun = args.includes("--dry-run"); - - console.log("=".repeat(60)); - console.log("Keycloak User Sync Script"); - console.log("=".repeat(60)); - console.log(`Mode: ${dryRun ? "DRY-RUN (no changes)" : "LIVE"}`); - console.log(""); - - // Initialize database - await AppDataSource.initialize(); - console.log("Database connected"); - - // Validate Keycloak connection - await keycloak.getToken(); - console.log("Keycloak connected"); - console.log(""); - - // Step 1: Delete existing users - console.log("Step 1: Deleting existing Keycloak users..."); - const deletedCount = await deleteAllKeycloakUsers(dryRun); - console.log(`Deleted ${deletedCount} users\n`); - - // Step 2: Sync profiles - console.log("Step 2: Creating users from profiles..."); - const result = await syncProfiles({ dryRun, delay: 100 }); - console.log(""); - - // Summary - console.log("=".repeat(60)); - console.log("Summary:"); - console.log(` Total profiles: ${result.total}`); - console.log(` Deleted users: ${deletedCount}`); - console.log(` Created users: ${result.created}`); - console.log(` Failed: ${result.failed}`); - console.log(` Skipped: ${result.skipped}`); - console.log("=".repeat(60)); - - if (result.errors.length > 0) { - console.log("\nErrors:"); - result.errors.forEach((e) => { - console.log(` ${e.citizenId}: ${e.error}`); - }); - } - - await AppDataSource.destroy(); -} - -main().catch(console.error); - -// add this line to package.json scripts section: -// "sync-keycloak": "ts-node scripts/sync-users-to-keycloak-null-only.ts", -// "sync-keycloak:dry": "ts-node scripts/sync-users-to-keycloak-null-only.ts --dry-run" diff --git a/src/app.ts b/src/app.ts index 75d0bfea..06f76548 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,6 +15,7 @@ import { logMemoryStore } from "./utils/LogMemoryStore"; import { orgStructureCache } from "./utils/OrgStructureCache"; import { CommandController } from "./controllers/CommandController"; import { ProfileSalaryController } from "./controllers/ProfileSalaryController"; +import { ScriptProfileOrgController } from "./controllers/ScriptProfileOrgController"; import { DateSerializer } from "./interfaces/date-serializer"; import { initWebSocket } from "./services/webSocket"; @@ -52,19 +53,8 @@ async function main() { const APP_HOST = process.env.APP_HOST || "0.0.0.0"; const APP_PORT = +(process.env.APP_PORT || 3000); - const cronTime = "0 0 3 * * *"; // ตั้งเวลาทุกวันเวลา 03:00:00 - // const cronTime = "*/10 * * * * *"; - cron.schedule(cronTime, async () => { - try { - const orgController = new OrganizationController(); - await orgController.cronjobRevision(); - } catch (error) { - console.error("Error executing function from controller:", error); - } - }); - - const cronTime_command = "0 0 2 * * *"; - // const cronTime_command = "*/10 * * * * *"; + // Cron job for executing command - every day at 00:30:00 + const cronTime_command = "0 30 0 * * *"; cron.schedule(cronTime_command, async () => { try { const commandController = new CommandController(); @@ -74,7 +64,19 @@ async function main() { } }); - const cronTime_Oct = "0 0 1 10 *"; + // Cron job for updating org revision - every day at 01:00:00 + const cronTime = "0 0 1 * * *"; + cron.schedule(cronTime, async () => { + try { + const orgController = new OrganizationController(); + await orgController.cronjobRevision(); + } catch (error) { + console.error("Error executing function from controller:", error); + } + }); + + // Cron job for updating retirement status - every day at 02:00:00 on the 1st of October + const cronTime_Oct = "0 0 2 10 *"; cron.schedule(cronTime_Oct, async () => { try { const commandController = new CommandController(); @@ -84,7 +86,19 @@ async function main() { } }); - const cronTime_Tenure = "0 0 0 * * *"; + // Cron job for updating org DNA - every day at 03:00:00 + const cronTime_UpdateOrg = "0 0 3 * * *"; + cron.schedule(cronTime_UpdateOrg, async () => { + try { + const scriptProfileOrgController = new ScriptProfileOrgController(); + await scriptProfileOrgController.cronjobUpdateOrg({} as any); + } catch (error) { + console.error("Error executing cronjobUpdateOrg:", error); + } + }); + + // Cron job for updating tenure - every day at 04:00:00 + const cronTime_Tenure = "0 0 4 * * *"; cron.schedule(cronTime_Tenure, async () => { try { const profileSalaryController = new ProfileSalaryController(); diff --git a/src/controllers/KeycloakSyncController.ts b/src/controllers/KeycloakSyncController.ts index 2a55983c..5f814238 100644 --- a/src/controllers/KeycloakSyncController.ts +++ b/src/controllers/KeycloakSyncController.ts @@ -1,12 +1,21 @@ -import { Controller, Post, Get, Route, Security, Tags, Path, Request, Response, Query } from "tsoa"; +import { + Controller, + Post, + Get, + Route, + Security, + Tags, + Path, + Request, + Response, + Query, + Body, +} from "tsoa"; import { KeycloakAttributeService } from "../services/KeycloakAttributeService"; import HttpSuccess from "../interfaces/http-success"; import HttpStatus from "../interfaces/http-status"; import HttpError from "../interfaces/http-error"; import { RequestWithUser } from "../middlewares/user"; -import { AppDataSource } from "../database/data-source"; -import { Profile } from "../entities/Profile"; -import { ProfileEmployee } from "../entities/ProfileEmployee"; @Route("api/v1/org/keycloak-sync") @Tags("Keycloak Sync") @@ -17,8 +26,6 @@ import { ProfileEmployee } from "../entities/ProfileEmployee"; ) export class KeycloakSyncController extends Controller { private keycloakAttributeService = new KeycloakAttributeService(); - private profileRepo = AppDataSource.getRepository(Profile); - private profileEmployeeRepo = AppDataSource.getRepository(ProfileEmployee); /** * Sync attributes for the current logged-in user @@ -62,7 +69,7 @@ export class KeycloakSyncController extends Controller { * * @summary Get current profileId and rootDnaId from Keycloak */ - @Get("my-attributes") + // @Get("my-attributes") async getMyAttributes(@Request() request: RequestWithUser) { const keycloakUserId = request.user.sub; @@ -117,19 +124,80 @@ export class KeycloakSyncController extends Controller { } /** - * Batch sync all users (Admin only) + * Batch sync attributes for multiple profiles (Admin only) * - * @summary Batch sync all users to Keycloak (ADMIN) + * @summary Batch sync profileId and rootDnaId to Keycloak for multiple profiles (ADMIN) * - * @param {number} limit Maximum number of users to sync + * @param {request} request Request body containing profileIds array and profileType */ - @Post("sync-all") - async syncAll(@Query() limit: number = 100) { - if (limit > 500) { - throw new HttpError(HttpStatus.BAD_REQUEST, "limit ต้องไม่เกิน 500"); + // @Post("sync-profiles-batch") + async syncByProfileIds( + @Body() request: { profileIds: string[]; profileType: "PROFILE" | "PROFILE_EMPLOYEE" }, + ) { + const { profileIds, profileType } = request; + + // Validate profileIds + if (!profileIds || profileIds.length === 0) { + throw new HttpError(HttpStatus.BAD_REQUEST, "profileIds ต้องไม่ว่างเปล่า"); } - const result = await this.keycloakAttributeService.batchSyncUsers(limit); + // Validate profileType + if (!["PROFILE", "PROFILE_EMPLOYEE"].includes(profileType)) { + throw new HttpError( + HttpStatus.BAD_REQUEST, + "profileType ต้องเป็น PROFILE หรือ PROFILE_EMPLOYEE เท่านั้น", + ); + } + + const result = { + total: profileIds.length, + success: 0, + failed: 0, + details: [] as Array<{ profileId: string; status: "success" | "failed"; error?: string }>, + }; + + // Process each profileId + for (const profileId of profileIds) { + try { + const success = await this.keycloakAttributeService.syncOnOrganizationChange( + profileId, + profileType, + ); + + if (success) { + result.success++; + result.details.push({ profileId, status: "success" }); + } else { + result.failed++; + result.details.push({ + profileId, + status: "failed", + error: "Sync returned false - ไม่พบข้อมูล profile หรือ Keycloak user ID", + }); + } + } catch (error: any) { + result.failed++; + result.details.push({ profileId, status: "failed", error: error.message }); + } + } + + return new HttpSuccess({ + message: "Batch sync เสร็จสิ้น", + ...result, + }); + } + + /** + * Batch sync all users (Admin only) + * + * @summary Batch sync all users to Keycloak without limit (ADMIN) + * + * @description Syncs profileId and orgRootDnaId to Keycloak for all users + * that have a keycloak ID. Uses parallel processing for better performance. + */ + // @Post("sync-all") + async syncAll() { + const result = await this.keycloakAttributeService.batchSyncUsers(); return new HttpSuccess({ message: "Batch sync เสร็จสิ้น", @@ -141,58 +209,46 @@ export class KeycloakSyncController extends Controller { } /** - * ตรวจสอบสถานะ Keycloak Mapper + * Ensure Keycloak users exist for all profiles (Admin only) * - * @summary ตรวจสอบว่า profileId และ rootDnaId ออกมาใน token หรือไม่ + * @summary Create or verify Keycloak users for all profiles in Profile and ProfileEmployee tables (ADMIN) + * + * @description + * This endpoint will: + * - Create new Keycloak users for profiles without a keycloak ID + * - Create new Keycloak users for profiles where the stored keycloak ID doesn't exist in Keycloak + * - Verify existing Keycloak users + * - Skip profiles without a citizenId */ - @Get("check-mapper") - async checkMapperStatus(@Request() request: RequestWithUser) { - const keycloakUserId = request.user.sub; - - if (!keycloakUserId) { - throw new HttpError(HttpStatus.UNAUTHORIZED, "ไม่พบ Keycloak user ID"); - } - - // 1. ตรวจสอบ attributes ใน Keycloak - const kcAttrs = - await this.keycloakAttributeService.getCurrentKeycloakAttributes(keycloakUserId); - - // 2. ตรวจสอบ attributes ใน Database - const dbAttrs = await this.keycloakAttributeService.getUserProfileAttributes(keycloakUserId); - - // 3. ตรวจสอบ token payload ปัจจุบัน - const tokenPayload = request.user; - + // @Post("ensure-users") + async ensureAllUsers() { + const result = await this.keycloakAttributeService.batchEnsureKeycloakUsers(); return new HttpSuccess({ - keycloakAttributes: kcAttrs, - databaseAttributes: dbAttrs, - tokenHasProfileId: !!tokenPayload.profileId, - tokenHasOrgRootDnaId: !!tokenPayload.orgRootDnaId, - tokenScopes: tokenPayload.scope?.split(" ") || [], - diagnosis: { - kcHasProfileId: !!kcAttrs?.profileId, - kcHasOrgRootDnaId: !!kcAttrs?.orgRootDnaId, - kcHasOrgChild1DnaId: !!kcAttrs?.orgChild1DnaId, - kcHasOrgChild2DnaId: !!kcAttrs?.orgChild2DnaId, - kcHasOrgChild3DnaId: !!kcAttrs?.orgChild3DnaId, - kcHasOrgChild4DnaId: !!kcAttrs?.orgChild4DnaId, - kcHasEmpType: !!kcAttrs?.empType, - dbHasProfileId: !!dbAttrs?.profileId, - dbHasOrgRootDnaId: !!dbAttrs?.orgRootDnaId, - dbHasOrgChild1DnaId: !!dbAttrs?.orgChild1DnaId, - dbHasOrgChild2DnaId: !!dbAttrs?.orgChild2DnaId, - dbHasOrgChild3DnaId: !!dbAttrs?.orgChild3DnaId, - dbHasOrgChild4DnaId: !!dbAttrs?.orgChild4DnaId, - dbHasEmpType: !!dbAttrs?.empType, - issue: - !tokenPayload.profileId && kcAttrs?.profileId - ? "Attribute มีใน Keycloak แต่ไม่ออกมาใน token - แก้ไข Mapper หรือ Client Scope" - : !kcAttrs?.profileId && dbAttrs?.profileId - ? "Attribute มีใน Database แต่ไม่มีใน Keycloak - ต้อง sync ซ้ำ" - : !dbAttrs?.profileId - ? "ไม่พบ profile ใน database - ตรวจสอบ keycloak field" - : "ทุกอย่างปกติ", - }, + message: "Batch ensure Keycloak users เสร็จสิ้น", + ...result, + }); + } + + /** + * Clear orphaned Keycloak users (Admin only) + * + * @summary Delete Keycloak users that are not in the database (ADMIN) + * + * @description + * This endpoint will: + * - Find users in Keycloak that are not referenced in Profile or ProfileEmployee tables + * - Delete those orphaned users from Keycloak + * - Skip protected users (super_admin, admin_issue) + * + * @param {request} request Request body containing skipUsernames array + */ + // @Post("clear-orphaned-users") + async clearOrphanedUsers(@Body() request?: { skipUsernames?: string[] }) { + const skipUsernames = request?.skipUsernames || ["super_admin", "admin_issue"]; + const result = await this.keycloakAttributeService.clearOrphanedKeycloakUsers(skipUsernames); + return new HttpSuccess({ + message: "Clear orphaned Keycloak users เสร็จสิ้น", + ...result, }); } } diff --git a/src/controllers/ScriptProfileOrgController.ts b/src/controllers/ScriptProfileOrgController.ts new file mode 100644 index 00000000..c8975e43 --- /dev/null +++ b/src/controllers/ScriptProfileOrgController.ts @@ -0,0 +1,326 @@ +import { Controller, Post, Route, Security, Tags, Request } from "tsoa"; +import { AppDataSource } from "../database/data-source"; +import HttpSuccess from "../interfaces/http-success"; +import HttpStatus from "../interfaces/http-status"; +import HttpError from "../interfaces/http-error"; +import { RequestWithUser } from "../middlewares/user"; +import { MoreThanOrEqual } from "typeorm"; +import { PosMaster } from "./../entities/PosMaster"; +import axios from "axios"; +import { KeycloakSyncController } from "./KeycloakSyncController"; +import { EmployeePosMaster } from "./../entities/EmployeePosMaster"; + +interface OrgUpdatePayload { + profileId: string; + rootDnaId: string | null; + child1DnaId: string | null; + child2DnaId: string | null; + child3DnaId: string | null; + child4DnaId: string | null; + profileType: "PROFILE" | "PROFILE_EMPLOYEE"; +} + +@Route("api/v1/org/script-profile-org") +@Tags("Keycloak Sync") +@Security("bearerAuth") +export class ScriptProfileOrgController extends Controller { + private posMasterRepo = AppDataSource.getRepository(PosMaster); + private employeePosMasterRepo = AppDataSource.getRepository(EmployeePosMaster); + + // Idempotency flag to prevent concurrent runs + private isRunning = false; + + // Configurable values + private readonly BATCH_SIZE = parseInt(process.env.CRONJOB_BATCH_SIZE || "100", 10); + private readonly UPDATE_WINDOW_HOURS = parseInt( + process.env.CRONJOB_UPDATE_WINDOW_HOURS || "24", + 10, + ); + + @Post("update-org") + public async cronjobUpdateOrg(@Request() request: RequestWithUser) { + // Idempotency check - prevent concurrent runs + if (this.isRunning) { + console.log("cronjobUpdateOrg: Job already running, skipping this execution"); + return new HttpSuccess({ + message: "Job already running", + skipped: true, + }); + } + + this.isRunning = true; + const startTime = Date.now(); + + try { + const windowStart = new Date(Date.now() - this.UPDATE_WINDOW_HOURS * 60 * 60 * 1000); + + console.log("cronjobUpdateOrg: Starting job", { + windowHours: this.UPDATE_WINDOW_HOURS, + windowStart: windowStart.toISOString(), + batchSize: this.BATCH_SIZE, + }); + + // Query with optimized select - only fetch required fields + const [posMasters, posMasterEmployee] = await Promise.all([ + this.posMasterRepo.find({ + where: { + lastUpdatedAt: MoreThanOrEqual(windowStart), + orgRevision: { + orgRevisionIsCurrent: true, + }, + }, + relations: [ + "orgRevision", + "orgRoot", + "orgChild1", + "orgChild2", + "orgChild3", + "orgChild4", + "current_holder", + ], + select: { + id: true, + current_holderId: true, + lastUpdatedAt: true, + orgRevision: { id: true }, + orgRoot: { ancestorDNA: true }, + orgChild1: { ancestorDNA: true }, + orgChild2: { ancestorDNA: true }, + orgChild3: { ancestorDNA: true }, + orgChild4: { ancestorDNA: true }, + current_holder: { id: true }, + }, + }), + this.employeePosMasterRepo.find({ + where: { + lastUpdatedAt: MoreThanOrEqual(windowStart), + orgRevision: { + orgRevisionIsCurrent: true, + }, + }, + relations: [ + "orgRevision", + "orgRoot", + "orgChild1", + "orgChild2", + "orgChild3", + "orgChild4", + "current_holder", + ], + select: { + id: true, + current_holderId: true, + lastUpdatedAt: true, + orgRevision: { id: true }, + orgRoot: { ancestorDNA: true }, + orgChild1: { ancestorDNA: true }, + orgChild2: { ancestorDNA: true }, + orgChild3: { ancestorDNA: true }, + orgChild4: { ancestorDNA: true }, + current_holder: { id: true }, + }, + }), + ]); + + console.log("cronjobUpdateOrg: Database query completed", { + posMastersCount: posMasters.length, + employeePosCount: posMasterEmployee.length, + totalRecords: posMasters.length + posMasterEmployee.length, + }); + + // Build payloads with proper profile type tracking + const payloads = this.buildPayloads(posMasters, posMasterEmployee); + + if (payloads.length === 0) { + console.log("cronjobUpdateOrg: No records to process"); + return new HttpSuccess({ + message: "No records to process", + processed: 0, + }); + } + + // Update profile's org structure in leave service by calling API + console.log("cronjobUpdateOrg: Calling leave service API", { + payloadCount: payloads.length, + }); + + await axios.put(`${process.env.API_URL}/leave-beginning/schedule/update-dna`, payloads, { + headers: { + "Content-Type": "application/json", + api_key: process.env.API_KEY, + }, + timeout: 30000, // 30 second timeout + }); + + console.log("cronjobUpdateOrg: Leave service API call successful"); + + // Group profile IDs by type for proper syncing + const profileIdsByType = this.groupProfileIdsByType(payloads); + + // Sync to Keycloak with batching + const keycloakSyncController = new KeycloakSyncController(); + const syncResults = { + total: 0, + success: 0, + failed: 0, + byType: {} as Record, + }; + + // Process each profile type separately + for (const [profileType, profileIds] of Object.entries(profileIdsByType)) { + console.log(`cronjobUpdateOrg: Syncing ${profileType} profiles`, { + count: profileIds.length, + }); + + const batches = this.chunkArray(profileIds, this.BATCH_SIZE); + const typeResult = { total: profileIds.length, success: 0, failed: 0 }; + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + console.log( + `cronjobUpdateOrg: Processing batch ${i + 1}/${batches.length} for ${profileType}`, + { + batchSize: batch.length, + batchRange: `${i * this.BATCH_SIZE + 1}-${Math.min( + (i + 1) * this.BATCH_SIZE, + profileIds.length, + )}`, + }, + ); + + try { + const batchResult: any = await keycloakSyncController.syncByProfileIds({ + profileIds: batch, + profileType: profileType as "PROFILE" | "PROFILE_EMPLOYEE", + }); + + // Extract result data if available + const resultData = (batchResult as any)?.data || batchResult; + typeResult.success += resultData.success || 0; + typeResult.failed += resultData.failed || 0; + + console.log(`cronjobUpdateOrg: Batch ${i + 1}/${batches.length} completed`, { + success: resultData.success || 0, + failed: resultData.failed || 0, + }); + } catch (error: any) { + console.error(`cronjobUpdateOrg: Batch ${i + 1}/${batches.length} failed`, { + error: error.message, + batchSize: batch.length, + }); + // Count all profiles in failed batch as failed + typeResult.failed += batch.length; + } + } + + syncResults.byType[profileType] = typeResult; + syncResults.total += typeResult.total; + syncResults.success += typeResult.success; + syncResults.failed += typeResult.failed; + } + + const duration = Date.now() - startTime; + console.log("cronjobUpdateOrg: Job completed", { + duration: `${duration}ms`, + processed: payloads.length, + syncResults, + }); + + return new HttpSuccess({ + message: "Update org completed", + processed: payloads.length, + syncResults, + duration: `${duration}ms`, + }); + } catch (error: any) { + const duration = Date.now() - startTime; + console.error("cronjobUpdateOrg: Job failed", { + duration: `${duration}ms`, + error: error.message, + stack: error.stack, + }); + throw new HttpError(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"); + } finally { + this.isRunning = false; + } + } + + /** + * Build payloads from PosMaster and EmployeePosMaster records + * Includes proper profile type tracking for accurate Keycloak sync + */ + private buildPayloads( + posMasters: PosMaster[], + posMasterEmployee: EmployeePosMaster[], + ): OrgUpdatePayload[] { + const payloads: OrgUpdatePayload[] = []; + + // Process PosMaster records (PROFILE type) + for (const posMaster of posMasters) { + if (posMaster.current_holder && posMaster.current_holderId) { + payloads.push({ + profileId: posMaster.current_holderId, + rootDnaId: posMaster.orgRoot?.ancestorDNA || null, + child1DnaId: posMaster.orgChild1?.ancestorDNA || null, + child2DnaId: posMaster.orgChild2?.ancestorDNA || null, + child3DnaId: posMaster.orgChild3?.ancestorDNA || null, + child4DnaId: posMaster.orgChild4?.ancestorDNA || null, + profileType: "PROFILE", + }); + } + } + + // Process EmployeePosMaster records (PROFILE_EMPLOYEE type) + for (const employeePos of posMasterEmployee) { + if (employeePos.current_holder && employeePos.current_holderId) { + payloads.push({ + profileId: employeePos.current_holderId, + rootDnaId: employeePos.orgRoot?.ancestorDNA || null, + child1DnaId: employeePos.orgChild1?.ancestorDNA || null, + child2DnaId: employeePos.orgChild2?.ancestorDNA || null, + child3DnaId: employeePos.orgChild3?.ancestorDNA || null, + child4DnaId: employeePos.orgChild4?.ancestorDNA || null, + profileType: "PROFILE_EMPLOYEE", + }); + } + } + + return payloads; + } + + /** + * Group profile IDs by their type for separate Keycloak sync calls + */ + private groupProfileIdsByType(payloads: OrgUpdatePayload[]): Record { + const grouped: Record = { + PROFILE: [], + PROFILE_EMPLOYEE: [], + }; + + for (const payload of payloads) { + grouped[payload.profileType].push(payload.profileId); + } + + // Remove empty groups and deduplicate IDs within each group + const result: Record = {}; + for (const [type, ids] of Object.entries(grouped)) { + if (ids.length > 0) { + // Deduplicate while preserving order + result[type] = Array.from(new Set(ids)); + } + } + + return result; + } + + /** + * Split array into chunks of specified size + */ + private chunkArray(array: T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += chunkSize) { + chunks.push(array.slice(i, i + chunkSize)); + } + return chunks; + } +} diff --git a/src/keycloak/index.ts b/src/keycloak/index.ts index d977c42d..835e31f2 100644 --- a/src/keycloak/index.ts +++ b/src/keycloak/index.ts @@ -4,8 +4,8 @@ const KC_URL = process.env.KC_URL; const KC_REALMS = process.env.KC_REALMS; const KC_CLIENT_ID = process.env.KC_SERVICE_ACCOUNT_CLIENT_ID; const KC_SECRET = process.env.KC_SERVICE_ACCOUNT_SECRET; -const AUTH_ACCOUNT_SECRET = process.env.AUTH_ACCOUNT_SECRET; -const API_KEY = process.env.API_KEY; +// const AUTH_ACCOUNT_SECRET = process.env.AUTH_ACCOUNT_SECRET; +// const API_KEY = process.env.API_KEY; let token: string | null = null; let decoded: DecodedJwt | null = null; @@ -165,16 +165,119 @@ export async function getUserList(first = "", max = "", search = "") { if (!res) return false; if (!res.ok) { - return Boolean(console.error("Keycloak Error Response: ", await res.json())); + const errorText = await res.text(); + return Boolean(console.error("Keycloak Error Response: ", errorText)); } - return ((await res.json()) as any[]).map((v: Record) => ({ + // Get raw text first to handle potential JSON parsing errors + const rawText = await res.text(); + + // Log response size for debugging + console.log(`[getUserList] Response size: ${rawText.length} bytes`); + + try { + const data = JSON.parse(rawText) as any[]; + return data.map((v: Record) => ({ + id: v.id, + username: v.username, + firstName: v.firstName, + lastName: v.lastName, + email: v.email, + enabled: v.enabled, + })); + } catch (error) { + console.error(`[getUserList] Failed to parse JSON response:`); + console.error(`[getUserList] Response preview (first 500 chars):`, rawText.substring(0, 500)); + console.error(`[getUserList] Response preview (last 200 chars):`, rawText.slice(-200)); + throw new Error( + `Failed to parse Keycloak response as JSON. Response may be truncated or malformed.`, + ); + } +} + +/** + * Get all keycloak users with pagination to avoid response size limits + * + * Client must have permission to manage realm's user + * + * @returns user list if success, false otherwise. + */ +export async function getAllUsersPaginated( + search: string = "", + batchSize: number = 100, +): Promise< + | Array<{ + id: string; + username: string; + firstName?: string; + lastName?: string; + email?: string; + enabled: boolean; + }> + | false +> { + const allUsers: any[] = []; + let first = 0; + let hasMore = true; + + while (hasMore) { + const res = await fetch( + `${KC_URL}/admin/realms/${KC_REALMS}/users?first=${first}&max=${batchSize}${search ? `&search=${search}` : ""}`, + { + headers: { + authorization: `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + }, + ).catch((e) => console.log("Keycloak Error: ", e)); + + if (!res) return false; + if (!res.ok) { + const errorText = await res.text(); + console.error("Keycloak Error Response: ", errorText); + return false; + } + + const rawText = await res.text(); + + try { + const batch = JSON.parse(rawText) as any[]; + + if (batch.length === 0) { + hasMore = false; + } else { + allUsers.push(...batch); + first += batch.length; + hasMore = batch.length === batchSize; + + // Log progress for large datasets + if (allUsers.length % 500 === 0) { + console.log(`[getAllUsersPaginated] Fetched ${allUsers.length} users so far...`); + } + } + } catch (error) { + console.error(`[getAllUsersPaginated] Failed to parse JSON response at offset ${first}:`); + console.error( + `[getAllUsersPaginated] Response preview (first 500 chars):`, + rawText.substring(0, 500), + ); + console.error( + `[getAllUsersPaginated] Response preview (last 200 chars):`, + rawText.slice(-200), + ); + throw new Error(`Failed to parse Keycloak response as JSON at batch starting at ${first}.`); + } + } + + console.log(`[getAllUsersPaginated] Total users fetched: ${allUsers.length}`); + + return allUsers.map((v: any) => ({ id: v.id, username: v.username, firstName: v.firstName, lastName: v.lastName, email: v.email, - enabled: v.enabled, + enabled: v.enabled === true || v.enabled === "true", })); } @@ -220,17 +323,34 @@ export async function getUserListOrg(first = "", max = "", search = "", userIds: if (!res) return false; if (!res.ok) { - return Boolean(console.error("Keycloak Error Response: ", await res.json())); + const errorText = await res.text(); + return Boolean(console.error("Keycloak Error Response: ", errorText)); } - return ((await res.json()) as any[]).map((v: Record) => ({ - id: v.id, - username: v.username, - firstName: v.firstName, - lastName: v.lastName, - email: v.email, - enabled: v.enabled, - })); + // Get raw text first to handle potential JSON parsing errors + const rawText = await res.text(); + + try { + const data = JSON.parse(rawText) as any[]; + return data.map((v: Record) => ({ + id: v.id, + username: v.username, + firstName: v.firstName, + lastName: v.lastName, + email: v.email, + enabled: v.enabled, + })); + } catch (error) { + console.error(`[getUserListOrg] Failed to parse JSON response:`); + console.error( + `[getUserListOrg] Response preview (first 500 chars):`, + rawText.substring(0, 500), + ); + console.error(`[getUserListOrg] Response preview (last 200 chars):`, rawText.slice(-200)); + throw new Error( + `Failed to parse Keycloak response as JSON. Response may be truncated or malformed.`, + ); + } } export async function getUserCountOrg(first = "", max = "", search = "", userIds: string[] = []) { @@ -444,10 +564,12 @@ export async function getRoles(name?: string, token?: string) { })); } - // return { - // id: data.id, - // name: data.name, - // }; + // Return single role object + return { + id: data.id, + name: data.name, + description: data.description, + }; } /** @@ -793,17 +915,20 @@ export async function updateUserAttributes( } // Merge existing attributes with new attributes - // Keycloak requires id to be present in the payload + // IMPORTANT: Spread all existing user fields to preserve firstName, lastName, email, etc. + // The Keycloak PUT endpoint performs a full update, so we must include all fields const updatedAttributes = { - id: existingUser.id, - enabled: existingUser.enabled ?? true, + ...existingUser, attributes: { ...(existingUser.attributes || {}), ...attributes, }, }; - console.log(`[updateUserAttributes] Sending to Keycloak:`, JSON.stringify(updatedAttributes, null, 2)); + console.log( + `[updateUserAttributes] Sending to Keycloak:`, + JSON.stringify(updatedAttributes, null, 2), + ); const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}`, { headers: { diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index 80fc9e92..9a571572 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -83,6 +83,7 @@ export async function expressAuthentication( request.app.locals.logData.orgChild3DnaId = payload.orgChild3DnaId ?? ""; request.app.locals.logData.orgChild4DnaId = payload.orgChild4DnaId ?? ""; request.app.locals.logData.empType = payload.empType ?? ""; + request.app.locals.logData.prefix = payload.prefix ?? ""; return payload; } diff --git a/src/middlewares/user.ts b/src/middlewares/user.ts index 43ac80a3..225f0a37 100644 --- a/src/middlewares/user.ts +++ b/src/middlewares/user.ts @@ -16,6 +16,7 @@ export type RequestWithUser = Request & { orgChild3DnaId?: string; orgChild4DnaId?: string; empType?: string; + prefix?: string; scope?: string; }; }; diff --git a/src/services/KeycloakAttributeService.ts b/src/services/KeycloakAttributeService.ts index 3ce04b1e..5b61e286 100644 --- a/src/services/KeycloakAttributeService.ts +++ b/src/services/KeycloakAttributeService.ts @@ -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( + 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; + } }