diff --git a/scripts/KEYCLOAK_SYNC_README.md b/scripts/KEYCLOAK_SYNC_README.md deleted file mode 100644 index 4c61ec75..00000000 --- a/scripts/KEYCLOAK_SYNC_README.md +++ /dev/null @@ -1,149 +0,0 @@ -# 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 \ No newline at end of file diff --git a/scripts/assign-user-role.ts b/scripts/assign-user-role.ts deleted file mode 100644 index 4161b31b..00000000 --- a/scripts/assign-user-role.ts +++ /dev/null @@ -1,269 +0,0 @@ -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 deleted file mode 100644 index 67eee99d..00000000 --- a/scripts/clear-orphaned-users.ts +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 14522d96..00000000 --- a/scripts/ensure-users.ts +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 9090dd17..00000000 --- a/scripts/sync-all.ts +++ /dev/null @@ -1,93 +0,0 @@ -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/src/app.ts b/src/app.ts index 29d50749..75d0bfea 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,7 +18,6 @@ import { ProfileSalaryController } from "./controllers/ProfileSalaryController"; import { DateSerializer } from "./interfaces/date-serializer"; import { initWebSocket } from "./services/webSocket"; -import { ScriptProfileOrgController } from "./controllers/ScriptProfileOrgController"; async function main() { await AppDataSource.initialize(); @@ -53,19 +52,8 @@ async function main() { const APP_HOST = process.env.APP_HOST || "0.0.0.0"; const APP_PORT = +(process.env.APP_PORT || 3000); - // 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(); - await commandController.cronjobCommand(); - } catch (error) { - console.error("Error executing function from controller:", error); - } - }); - - // Cron job for updating org revision - every day at 01:00:00 - const cronTime = "0 0 1 * * *"; + const cronTime = "0 0 3 * * *"; // ตั้งเวลาทุกวันเวลา 03:00:00 + // const cronTime = "*/10 * * * * *"; cron.schedule(cronTime, async () => { try { const orgController = new OrganizationController(); @@ -75,8 +63,18 @@ async function main() { } }); - // Cron job for updating retirement status - every day at 02:00:00 on the 1st of October - const cronTime_Oct = "0 0 2 10 *"; + const cronTime_command = "0 0 2 * * *"; + // const cronTime_command = "*/10 * * * * *"; + cron.schedule(cronTime_command, async () => { + try { + const commandController = new CommandController(); + await commandController.cronjobCommand(); + } catch (error) { + console.error("Error executing function from controller:", error); + } + }); + + const cronTime_Oct = "0 0 1 10 *"; cron.schedule(cronTime_Oct, async () => { try { const commandController = new CommandController(); @@ -86,19 +84,7 @@ async function main() { } }); - // 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 * * *"; + const cronTime_Tenure = "0 0 0 * * *"; cron.schedule(cronTime_Tenure, async () => { try { const profileSalaryController = new ProfileSalaryController(); diff --git a/src/controllers/KeycloakSyncController.ts b/src/controllers/KeycloakSyncController.ts deleted file mode 100644 index 5f814238..00000000 --- a/src/controllers/KeycloakSyncController.ts +++ /dev/null @@ -1,254 +0,0 @@ -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"; - -@Route("api/v1/org/keycloak-sync") -@Tags("Keycloak Sync") -@Security("bearerAuth") -@Response( - HttpStatus.INTERNAL_SERVER_ERROR, - "เกิดข้อผิดพลาด ไม่สามารถดำเนินการได้ กรุณาลองใหม่ในภายหลัง", -) -export class KeycloakSyncController extends Controller { - private keycloakAttributeService = new KeycloakAttributeService(); - - /** - * Sync attributes for the current logged-in user - * - * @summary Sync profileId and rootDnaId to Keycloak for current user - */ - @Post("sync-me") - async syncCurrentUser(@Request() request: RequestWithUser) { - const keycloakUserId = request.user.sub; - - if (!keycloakUserId) { - throw new HttpError(HttpStatus.UNAUTHORIZED, "ไม่พบ Keycloak user ID"); - } - - // Get attributes from database before sync - const dbAttrs = await this.keycloakAttributeService.getUserProfileAttributes(keycloakUserId); - - const success = await this.keycloakAttributeService.syncUserAttributes(keycloakUserId); - - if (!success) { - throw new HttpError( - HttpStatus.INTERNAL_SERVER_ERROR, - "ไม่สามารถ sync ข้อมูลไปยัง Keycloak ได้ กรุณาติดต่อผู้ดูแลระบบ", - ); - } - - // Verify sync by fetching attributes from Keycloak after update - const kcAttrsAfter = - await this.keycloakAttributeService.getCurrentKeycloakAttributes(keycloakUserId); - - return new HttpSuccess({ - message: "Sync ข้อมูลสำเร็จ", - syncedToKeycloak: !!kcAttrsAfter?.profileId, - databaseAttributes: dbAttrs, - keycloakAttributesAfter: kcAttrsAfter, - }); - } - - /** - * Get current attributes of the logged-in user - * - * @summary Get current profileId and rootDnaId from Keycloak - */ - // @Get("my-attributes") - async getMyAttributes(@Request() request: RequestWithUser) { - const keycloakUserId = request.user.sub; - - if (!keycloakUserId) { - throw new HttpError(HttpStatus.UNAUTHORIZED, "ไม่พบ Keycloak user ID"); - } - - const keycloakAttributes = - await this.keycloakAttributeService.getCurrentKeycloakAttributes(keycloakUserId); - const dbAttributes = - await this.keycloakAttributeService.getUserProfileAttributes(keycloakUserId); - - return new HttpSuccess({ - keycloakAttributes, - databaseAttributes: dbAttributes, - }); - } - - /** - * Sync attributes for a specific profile (Admin only) - * - * @summary Sync profileId and rootDnaId to Keycloak by profile ID (ADMIN) - * - * @param {string} profileId Profile ID - * @param {string} profileType Profile type (PROFILE or PROFILE_EMPLOYEE) - */ - @Post("sync-profile/:profileId") - async syncByProfileId( - @Path() profileId: string, - @Query() profileType: "PROFILE" | "PROFILE_EMPLOYEE" = "PROFILE", - ) { - if (!["PROFILE", "PROFILE_EMPLOYEE"].includes(profileType)) { - throw new HttpError( - HttpStatus.BAD_REQUEST, - "profileType ต้องเป็น PROFILE หรือ PROFILE_EMPLOYEE เท่านั้น", - ); - } - - const success = await this.keycloakAttributeService.syncOnOrganizationChange( - profileId, - profileType, - ); - - if (!success) { - throw new HttpError( - HttpStatus.INTERNAL_SERVER_ERROR, - "ไม่สามารถ sync ข้อมูลไปยัง Keycloak ได้ หรือไม่พบข้อมูล profile", - ); - } - - return new HttpSuccess({ message: "Sync ข้อมูลสำเร็จ" }); - } - - /** - * Batch sync attributes for multiple profiles (Admin only) - * - * @summary Batch sync profileId and rootDnaId to Keycloak for multiple profiles (ADMIN) - * - * @param {request} request Request body containing profileIds array and profileType - */ - // @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 ต้องไม่ว่างเปล่า"); - } - - // 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 เสร็จสิ้น", - total: result.total, - success: result.success, - failed: result.failed, - details: result.details, - }); - } - - /** - * Ensure Keycloak users exist for all profiles (Admin only) - * - * @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 - */ - // @Post("ensure-users") - async ensureAllUsers() { - const result = await this.keycloakAttributeService.batchEnsureKeycloakUsers(); - return new HttpSuccess({ - 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 deleted file mode 100644 index c8975e43..00000000 --- a/src/controllers/ScriptProfileOrgController.ts +++ /dev/null @@ -1,326 +0,0 @@ -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 835e31f2..a81e2af9 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,119 +165,16 @@ export async function getUserList(first = "", max = "", search = "") { if (!res) return false; if (!res.ok) { - const errorText = await res.text(); - return Boolean(console.error("Keycloak Error Response: ", errorText)); + return Boolean(console.error("Keycloak Error Response: ", await res.json())); } - // 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) => ({ + 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 === true || v.enabled === "true", + enabled: v.enabled, })); } @@ -323,34 +220,17 @@ export async function getUserListOrg(first = "", max = "", search = "", userIds: if (!res) return false; if (!res.ok) { - const errorText = await res.text(); - return Boolean(console.error("Keycloak Error Response: ", errorText)); + return Boolean(console.error("Keycloak Error Response: ", await res.json())); } - // 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.`, - ); - } + 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, + })); } export async function getUserCountOrg(first = "", max = "", search = "", userIds: string[] = []) { @@ -564,12 +444,10 @@ export async function getRoles(name?: string, token?: string) { })); } - // Return single role object - return { - id: data.id, - name: data.name, - description: data.description, - }; + // return { + // id: data.id, + // name: data.name, + // }; } /** @@ -894,73 +772,6 @@ export async function changeUserPassword(userId: string, newPassword: string) { } } -/** - * Update user attributes in Keycloak - * - * @param userId - Keycloak user ID - * @param attributes - Object containing attribute names and their values (as arrays) - * @returns true if success, false otherwise - */ -export async function updateUserAttributes( - userId: string, - attributes: Record, -): Promise { - try { - // Get existing user data to preserve other attributes - const existingUser = await getUser(userId); - - if (!existingUser) { - console.error(`User ${userId} not found in Keycloak`); - return false; - } - - // Merge existing attributes with new attributes - // 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 = { - ...existingUser, - attributes: { - ...(existingUser.attributes || {}), - ...attributes, - }, - }; - - console.log( - `[updateUserAttributes] Sending to Keycloak:`, - JSON.stringify(updatedAttributes, null, 2), - ); - - const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}`, { - headers: { - authorization: `Bearer ${await getToken()}`, - "content-type": "application/json", - }, - method: "PUT", - body: JSON.stringify(updatedAttributes), - }).catch((e) => { - console.error(`[updateUserAttributes] Network error:`, e); - return null; - }); - - if (!res) { - console.error(`[updateUserAttributes] No response from Keycloak`); - return false; - } - - if (!res.ok) { - const errorText = await res.text(); - console.error(`[updateUserAttributes] Keycloak Error (${res.status}):`, errorText); - return false; - } - - console.log(`[updateUserAttributes] Successfully updated attributes for user ${userId}`); - return true; - } catch (error) { - console.error(`[updateUserAttributes] Error updating attributes for user ${userId}:`, error); - return false; - } -} - // Function to reset password export async function resetPassword(username: string) { try { diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index 9a571572..c27e6188 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -75,16 +75,6 @@ export async function expressAuthentication( request.app.locals.logData.userName = payload.name; request.app.locals.logData.user = payload.preferred_username; - // เก็บค่า profileId และ orgRootDnaId จาก token (ใช้ค่าว่างถ้าไม่มี) - request.app.locals.logData.profileId = payload.profileId ?? ""; - request.app.locals.logData.orgRootDnaId = payload.orgRootDnaId ?? ""; - request.app.locals.logData.orgChild1DnaId = payload.orgChild1DnaId ?? ""; - request.app.locals.logData.orgChild2DnaId = payload.orgChild2DnaId ?? ""; - 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 75c84d01..e5c48d9a 100644 --- a/src/middlewares/user.ts +++ b/src/middlewares/user.ts @@ -9,14 +9,6 @@ export type RequestWithUser = Request & { preferred_username: string; email: string; role: string[]; - profileId?: string; - prefix?: string; - orgRootDnaId?: string; - orgChild1DnaId?: string; - orgChild2DnaId?: string; - orgChild3DnaId?: string; - orgChild4DnaId?: string; - empType?: string; }; }; diff --git a/src/services/KeycloakAttributeService.ts b/src/services/KeycloakAttributeService.ts deleted file mode 100644 index 5b61e286..00000000 --- a/src/services/KeycloakAttributeService.ts +++ /dev/null @@ -1,928 +0,0 @@ -import { AppDataSource } from "../database/data-source"; -import { Profile } from "../entities/Profile"; -import { ProfileEmployee } from "../entities/ProfileEmployee"; -// import { PosMaster } from "../entities/PosMaster"; -// import { EmployeePosMaster } from "../entities/EmployeePosMaster"; -// import { OrgRoot } from "../entities/OrgRoot"; -import { - createUser, - getUser, - getUserByUsername, - updateUserAttributes, - deleteUser, - getRoles, - addUserRoles, - getAllUsersPaginated, -} from "../keycloak"; -import { OrgRevision } from "../entities/OrgRevision"; - -export interface UserProfileAttributes { - profileId: string | null; - orgRootDnaId: string | null; - orgChild1DnaId: string | null; - orgChild2DnaId: string | null; - orgChild3DnaId: string | null; - orgChild4DnaId: string | null; - empType: string | null; - prefix?: string | null; -} - -/** - * Keycloak Attribute Service - * Service for syncing profileId and orgRootDnaId to Keycloak user attributes - */ -export class KeycloakAttributeService { - private profileRepo = AppDataSource.getRepository(Profile); - private profileEmployeeRepo = AppDataSource.getRepository(ProfileEmployee); - // private posMasterRepo = AppDataSource.getRepository(PosMaster); - // private employeePosMasterRepo = AppDataSource.getRepository(EmployeePosMaster); - // private orgRootRepo = AppDataSource.getRepository(OrgRoot); - private orgRevisionRepo = AppDataSource.getRepository(OrgRevision); - - /** - * Get profile attributes (profileId and orgRootDnaId) from database - * Searches in Profile table first (ข้าราชการ), then ProfileEmployee (ลูกจ้าง) - * - * @param keycloakUserId - Keycloak user ID - * @returns UserProfileAttributes with profileId and orgRootDnaId - */ - async getUserProfileAttributes(keycloakUserId: string): Promise { - // First, try to find in Profile (ข้าราชการ) - const revisionCurrent = await this.orgRevisionRepo.findOne({ - where: { orgRevisionIsCurrent: true }, - }); - const revisionId = revisionCurrent ? revisionCurrent.id : null; - const profileResult = await this.profileRepo - .createQueryBuilder("p") - .leftJoinAndSelect("p.current_holders", "pm") - .leftJoinAndSelect("pm.orgRoot", "orgRoot") - .leftJoinAndSelect("pm.orgChild1", "orgChild1") - .leftJoinAndSelect("pm.orgChild2", "orgChild2") - .leftJoinAndSelect("pm.orgChild3", "orgChild3") - .leftJoinAndSelect("pm.orgChild4", "orgChild4") - .where("p.keycloak = :keycloakUserId", { keycloakUserId }) - .andWhere("orgRoot.orgRevisionId = :revisionId", { revisionId }) - .getOne(); - - if ( - profileResult && - profileResult.current_holders && - profileResult.current_holders.length > 0 - ) { - const currentPos = profileResult.current_holders[0]; - const orgRootDnaId = currentPos.orgRoot?.ancestorDNA || ""; - const orgChild1DnaId = currentPos.orgChild1?.ancestorDNA || ""; - const orgChild2DnaId = currentPos.orgChild2?.ancestorDNA || ""; - const orgChild3DnaId = currentPos.orgChild3?.ancestorDNA || ""; - const orgChild4DnaId = currentPos.orgChild4?.ancestorDNA || ""; - return { - profileId: profileResult.id, - orgRootDnaId, - orgChild1DnaId, - orgChild2DnaId, - orgChild3DnaId, - orgChild4DnaId, - empType: "OFFICER", - prefix: profileResult.prefix, - }; - } - - // If not found in Profile, try ProfileEmployee (ลูกจ้าง) - const profileEmployeeResult = await this.profileEmployeeRepo - .createQueryBuilder("pe") - .leftJoinAndSelect("pe.current_holders", "epm") - .leftJoinAndSelect("epm.orgRoot", "org") - .leftJoinAndSelect("epm.orgChild1", "orgChild1") - .leftJoinAndSelect("epm.orgChild2", "orgChild2") - .leftJoinAndSelect("epm.orgChild3", "orgChild3") - .leftJoinAndSelect("epm.orgChild4", "orgChild4") - .where("pe.keycloak = :keycloakUserId", { keycloakUserId }) - .getOne(); - - if ( - profileEmployeeResult && - profileEmployeeResult.current_holders && - profileEmployeeResult.current_holders.length > 0 - ) { - const currentPos = profileEmployeeResult.current_holders[0]; - const orgRootDnaId = currentPos.orgRoot?.ancestorDNA || ""; - const orgChild1DnaId = currentPos.orgChild1?.ancestorDNA || ""; - const orgChild2DnaId = currentPos.orgChild2?.ancestorDNA || ""; - const orgChild3DnaId = currentPos.orgChild3?.ancestorDNA || ""; - const orgChild4DnaId = currentPos.orgChild4?.ancestorDNA || ""; - return { - profileId: profileEmployeeResult.id, - orgRootDnaId, - orgChild1DnaId, - orgChild2DnaId, - orgChild3DnaId, - orgChild4DnaId, - empType: profileEmployeeResult.employeeClass, - prefix: profileEmployeeResult.prefix, - }; - } - - // Return null values if no profile found - return { - profileId: null, - orgRootDnaId: null, - orgChild1DnaId: null, - orgChild2DnaId: null, - orgChild3DnaId: null, - orgChild4DnaId: null, - empType: null, - prefix: null, - }; - } - - /** - * Get profile attributes by profile ID directly - * Used for syncing specific profiles - * - * @param profileId - Profile ID - * @param profileType - 'PROFILE' for ข้าราชการ or 'PROFILE_EMPLOYEE' for ลูกจ้าง - * @returns UserProfileAttributes with profileId and orgRootDnaId - */ - async getAttributesByProfileId( - profileId: string, - profileType: "PROFILE" | "PROFILE_EMPLOYEE", - ): Promise { - const revisionCurrent = await this.orgRevisionRepo.findOne({ - where: { orgRevisionIsCurrent: true }, - }); - const revisionId = revisionCurrent ? revisionCurrent.id : null; - if (profileType === "PROFILE") { - const profileResult = await this.profileRepo - .createQueryBuilder("p") - .leftJoinAndSelect("p.current_holders", "pm") - .leftJoinAndSelect("pm.orgRoot", "orgRoot") - .leftJoinAndSelect("pm.orgChild1", "orgChild1") - .leftJoinAndSelect("pm.orgChild2", "orgChild2") - .leftJoinAndSelect("pm.orgChild3", "orgChild3") - .leftJoinAndSelect("pm.orgChild4", "orgChild4") - .where("p.id = :profileId", { profileId }) - .andWhere("orgRoot.orgRevisionId = :revisionId", { revisionId }) - .getOne(); - - if ( - profileResult && - profileResult.current_holders && - profileResult.current_holders.length > 0 - ) { - const currentPos = profileResult.current_holders[0]; - const orgRootDnaId = currentPos.orgRoot?.ancestorDNA || ""; - const orgChild1DnaId = currentPos.orgChild1?.ancestorDNA || ""; - const orgChild2DnaId = currentPos.orgChild2?.ancestorDNA || ""; - const orgChild3DnaId = currentPos.orgChild3?.ancestorDNA || ""; - const orgChild4DnaId = currentPos.orgChild4?.ancestorDNA || ""; - return { - profileId: profileResult.id, - orgRootDnaId, - orgChild1DnaId, - orgChild2DnaId, - orgChild3DnaId, - orgChild4DnaId, - empType: "OFFICER", - prefix: profileResult.prefix, - }; - } - } else { - const profileEmployeeResult = await this.profileEmployeeRepo - .createQueryBuilder("pe") - .leftJoinAndSelect("pe.current_holders", "epm") - .leftJoinAndSelect("epm.orgRoot", "org") - .leftJoinAndSelect("pm.orgChild1", "orgChild1") - .leftJoinAndSelect("pm.orgChild2", "orgChild2") - .leftJoinAndSelect("pm.orgChild3", "orgChild3") - .leftJoinAndSelect("pm.orgChild4", "orgChild4") - .where("pe.id = :profileId", { profileId }) - .getOne(); - - if ( - profileEmployeeResult && - profileEmployeeResult.current_holders && - profileEmployeeResult.current_holders.length > 0 - ) { - const currentPos = profileEmployeeResult.current_holders[0]; - const orgRootDnaId = currentPos.orgRoot?.ancestorDNA || ""; - const orgChild1DnaId = currentPos.orgChild1?.ancestorDNA || ""; - const orgChild2DnaId = currentPos.orgChild2?.ancestorDNA || ""; - const orgChild3DnaId = currentPos.orgChild3?.ancestorDNA || ""; - const orgChild4DnaId = currentPos.orgChild4?.ancestorDNA || ""; - - return { - profileId: profileEmployeeResult.id, - orgRootDnaId, - orgChild1DnaId, - orgChild2DnaId, - orgChild3DnaId, - orgChild4DnaId, - empType: profileEmployeeResult.employeeClass, - prefix: profileEmployeeResult.prefix, - }; - } - } - - return { - profileId: null, - orgRootDnaId: null, - orgChild1DnaId: null, - orgChild2DnaId: null, - orgChild3DnaId: null, - orgChild4DnaId: null, - empType: null, - prefix: null, - }; - } - - /** - * Sync user attributes to Keycloak - * - * @param keycloakUserId - Keycloak user ID - * @returns true if sync successful, false otherwise - */ - async syncUserAttributes(keycloakUserId: string): Promise { - try { - const attributes = await this.getUserProfileAttributes(keycloakUserId); - - if (!attributes.profileId) { - console.log(`No profile found for Keycloak user ${keycloakUserId}`); - return false; - } - - // Prepare attributes for Keycloak (must be arrays) - const keycloakAttributes: Record = { - profileId: [attributes.profileId], - orgRootDnaId: [attributes.orgRootDnaId || ""], - orgChild1DnaId: [attributes.orgChild1DnaId || ""], - orgChild2DnaId: [attributes.orgChild2DnaId || ""], - orgChild3DnaId: [attributes.orgChild3DnaId || ""], - orgChild4DnaId: [attributes.orgChild4DnaId || ""], - empType: [attributes.empType || ""], - prefix: [attributes.prefix || ""], - }; - - const success = await updateUserAttributes(keycloakUserId, keycloakAttributes); - - if (success) { - console.log(`Synced attributes for Keycloak user ${keycloakUserId}:`, attributes); - } - - return success; - } catch (error) { - console.error(`Error syncing attributes for Keycloak user ${keycloakUserId}:`, error); - return false; - } - } - - /** - * Sync attributes when organization changes - * This is called when a user moves to a different organization - * - * @param profileId - Profile ID - * @param profileType - 'PROFILE' for ข้าราชการ or 'PROFILE_EMPLOYEE' for ลูกจ้าง - * @returns true if sync successful, false otherwise - */ - async syncOnOrganizationChange( - profileId: string, - profileType: "PROFILE" | "PROFILE_EMPLOYEE", - ): Promise { - try { - // Get the keycloak userId from the profile - let keycloakUserId: string | null = null; - - if (profileType === "PROFILE") { - const profile = await this.profileRepo.findOne({ where: { id: profileId } }); - keycloakUserId = profile?.keycloak || ""; - } else { - const profileEmployee = await this.profileEmployeeRepo.findOne({ - where: { id: profileId }, - }); - keycloakUserId = profileEmployee?.keycloak || ""; - } - - if (!keycloakUserId) { - console.log(`No Keycloak user ID found for profile ${profileId}`); - return false; - } - - return await this.syncUserAttributes(keycloakUserId); - } catch (error) { - console.error(`Error syncing organization change for profile ${profileId}:`, error); - return false; - } - } - - /** - * Batch sync multiple users with unlimited count and parallel processing - * Useful for initial sync or periodic updates - * - * @param options - Optional configuration (limit for testing, concurrency for parallel processing) - * @returns Object with success count and details - */ - async batchSyncUsers(options?: { - limit?: number; - concurrency?: number; - }): Promise<{ total: number; success: number; failed: number; details: any[] }> { - const limit = options?.limit; - const concurrency = options?.concurrency ?? 5; - - const result = { - total: 0, - success: 0, - failed: 0, - details: [] as any[], - }; - - try { - // Build query for profiles with keycloak IDs (ข้าราชการ) - const profileQuery = this.profileRepo - .createQueryBuilder("p") - .where("p.keycloak IS NOT NULL") - .andWhere("p.keycloak != :empty", { empty: "" }); - - // Build query for profileEmployees with keycloak IDs (ลูกจ้าง) - const profileEmployeeQuery = this.profileEmployeeRepo - .createQueryBuilder("pe") - .where("pe.keycloak IS NOT NULL") - .andWhere("pe.keycloak != :empty", { empty: "" }); - - // Apply limit if specified (for testing purposes) - if (limit !== undefined) { - profileQuery.take(limit); - profileEmployeeQuery.take(limit); - } - - // Get profiles from both tables - const [profiles, profileEmployees] = await Promise.all([ - profileQuery.getMany(), - profileEmployeeQuery.getMany(), - ]); - - const allProfiles = [ - ...profiles.map((p) => ({ profile: p, type: "PROFILE" as const })), - ...profileEmployees.map((p) => ({ profile: p, type: "PROFILE_EMPLOYEE" as const })), - ]; - - result.total = allProfiles.length; - - // Process in parallel with concurrency limit - const processedResults = await this.processInParallel( - allProfiles, - concurrency, - async ({ profile, type }, _index) => { - const keycloakUserId = profile.keycloak; - - try { - const success = await this.syncOnOrganizationChange(profile.id, type); - if (success) { - result.success++; - return { - profileId: profile.id, - keycloakUserId, - status: "success", - }; - } else { - result.failed++; - return { - profileId: profile.id, - keycloakUserId, - status: "failed", - error: "Sync returned false", - }; - } - } catch (error: any) { - result.failed++; - return { - profileId: profile.id, - keycloakUserId, - status: "error", - error: error.message, - }; - } - }, - ); - - // Separate results from errors - for (const resultItem of processedResults) { - if ("error" in resultItem) { - result.failed++; - result.details.push({ - profileId: "unknown", - keycloakUserId: "unknown", - status: "error", - error: JSON.stringify(resultItem.error), - }); - } else { - result.details.push(resultItem); - } - } - - console.log( - `Batch sync completed: total=${result.total}, success=${result.success}, failed=${result.failed}`, - ); - } catch (error) { - console.error("Error in batch sync:", error); - } - - return result; - } - - /** - * Get current Keycloak attributes for a user - * - * @param keycloakUserId - Keycloak user ID - * @returns Current attributes from Keycloak - */ - async getCurrentKeycloakAttributes( - keycloakUserId: string, - ): Promise { - try { - const user = await getUser(keycloakUserId); - - if (!user || !user.attributes) { - return null; - } - - return { - profileId: user.attributes.profileId?.[0] || "", - orgRootDnaId: user.attributes.orgRootDnaId?.[0] || "", - orgChild1DnaId: user.attributes.orgChild1DnaId?.[0] || "", - orgChild2DnaId: user.attributes.orgChild2DnaId?.[0] || "", - orgChild3DnaId: user.attributes.orgChild3DnaId?.[0] || "", - orgChild4DnaId: user.attributes.orgChild4DnaId?.[0] || "", - empType: user.attributes.empType?.[0] || "", - prefix: user.attributes.prefix?.[0] || "", - }; - } catch (error) { - console.error(`Error getting Keycloak attributes for user ${keycloakUserId}:`, error); - return null; - } - } - - /** - * Ensure Keycloak user exists for a profile - * Creates user if keycloak field is empty OR if stored keycloak ID doesn't exist in Keycloak - * - * @param profileId - Profile ID - * @param profileType - 'PROFILE' or 'PROFILE_EMPLOYEE' - * @returns Object with status and details - */ - async ensureKeycloakUser( - profileId: string, - profileType: "PROFILE" | "PROFILE_EMPLOYEE", - ): Promise<{ - success: boolean; - action: "created" | "verified" | "skipped" | "error"; - keycloakUserId?: string; - error?: string; - }> { - try { - // Get profile from database - let profile: Profile | ProfileEmployee | null = null; - - if (profileType === "PROFILE") { - profile = await this.profileRepo.findOne({ where: { id: profileId } }); - } else { - profile = await this.profileEmployeeRepo.findOne({ where: { id: profileId } }); - } - - if (!profile) { - return { - success: false, - action: "error", - error: `Profile ${profileId} not found in database`, - }; - } - - // Check if citizenId exists - if (!profile.citizenId) { - return { - success: false, - action: "skipped", - error: "No citizenId found", - }; - } - - // Case 1: keycloak field is empty -> create new user - if (!profile.keycloak || profile.keycloak.trim() === "") { - const result = await this.createKeycloakUserFromProfile(profile, profileType); - return result; - } - - // Case 2: keycloak field is not empty -> verify user exists in Keycloak - const existingUser = await getUser(profile.keycloak); - - if (!existingUser) { - // User doesn't exist in Keycloak, create new one - console.log( - `Keycloak user ${profile.keycloak} not found in Keycloak, creating new user for profile ${profileId}`, - ); - const result = await this.createKeycloakUserFromProfile(profile, profileType); - return result; - } - - // User exists in Keycloak, verified - return { - success: true, - action: "verified", - keycloakUserId: profile.keycloak, - }; - } catch (error: any) { - console.error(`Error ensuring Keycloak user for profile ${profileId}:`, error); - return { - success: false, - action: "error", - error: error.message || "Unknown error", - }; - } - } - - /** - * Create Keycloak user from profile data - * - * @param profile - Profile or ProfileEmployee entity - * @param profileType - 'PROFILE' or 'PROFILE_EMPLOYEE' - * @returns Object with status and details - */ - private async createKeycloakUserFromProfile( - profile: Profile | ProfileEmployee, - profileType: "PROFILE" | "PROFILE_EMPLOYEE", - ): Promise<{ - success: boolean; - action: "created" | "verified" | "skipped" | "error"; - keycloakUserId?: string; - error?: string; - }> { - try { - // Check if user already exists by username (citizenId) - const existingUserByUsername = await getUserByUsername(profile.citizenId); - if (Array.isArray(existingUserByUsername) && existingUserByUsername.length > 0) { - // User already exists with this username, update the keycloak field - const existingUserId = existingUserByUsername[0].id; - console.log( - `User with citizenId ${profile.citizenId} already exists in Keycloak with ID ${existingUserId}`, - ); - - // Update the keycloak field in database - if (profileType === "PROFILE") { - await this.profileRepo.update(profile.id, { keycloak: existingUserId }); - } else { - await this.profileEmployeeRepo.update(profile.id, { keycloak: existingUserId }); - } - - // Assign default USER role to existing user - const userRole = await getRoles("USER"); - if (userRole && typeof userRole === "object" && "id" in userRole && "name" in userRole) { - const roleAssigned = await addUserRoles(existingUserId, [ - { id: String(userRole.id), name: String(userRole.name) }, - ]); - if (roleAssigned) { - console.log(`Assigned USER role to existing user ${existingUserId}`); - } else { - console.warn(`Failed to assign USER role to existing user ${existingUserId}`); - } - } else { - console.warn(`USER role not found in Keycloak`); - } - - return { - success: true, - action: "verified", - keycloakUserId: existingUserId, - }; - } - - // Create new user in Keycloak - const createResult = await createUser(profile.citizenId, "P@ssw0rd", { - firstName: profile.firstName || "", - lastName: profile.lastName || "", - email: profile.email || undefined, - enabled: true, - }); - - if (!createResult || typeof createResult !== "string") { - return { - success: false, - action: "error", - error: "Failed to create user in Keycloak", - }; - } - - const keycloakUserId = createResult; - - // Update the keycloak field in database - if (profileType === "PROFILE") { - await this.profileRepo.update(profile.id, { keycloak: keycloakUserId }); - } else { - await this.profileEmployeeRepo.update(profile.id, { keycloak: keycloakUserId }); - } - - // Assign default USER role - const userRole = await getRoles("USER"); - if (userRole && typeof userRole === "object" && "id" in userRole && "name" in userRole) { - const roleAssigned = await addUserRoles(keycloakUserId, [ - { id: String(userRole.id), name: String(userRole.name) }, - ]); - if (roleAssigned) { - console.log(`Assigned USER role to user ${keycloakUserId}`); - } else { - console.warn(`Failed to assign USER role to user ${keycloakUserId}`); - } - } else { - console.warn(`USER role not found in Keycloak`); - } - - console.log( - `Created Keycloak user for profile ${profile.id} (citizenId: ${profile.citizenId}) with ID ${keycloakUserId}`, - ); - - return { - success: true, - action: "created", - keycloakUserId, - }; - } catch (error: any) { - console.error(`Error creating Keycloak user for profile ${profile.id}:`, error); - return { - success: false, - action: "error", - error: error.message || "Unknown error", - }; - } - } - - /** - * Process items in parallel with concurrency limit - */ - private async processInParallel( - items: T[], - concurrencyLimit: number, - processor: (item: T, index: number) => Promise, - ): Promise> { - const results: Array = []; - - // Process items in batches - for (let i = 0; i < items.length; i += concurrencyLimit) { - const batch = items.slice(i, i + concurrencyLimit); - - // Process batch in parallel with error handling - const batchResults = await Promise.all( - batch.map(async (item, batchIndex) => { - try { - return await processor(item, i + batchIndex); - } catch (error) { - return { error }; - } - }), - ); - - results.push(...batchResults); - - // Log progress after each batch - const completed = Math.min(i + concurrencyLimit, items.length); - console.log(`Progress: ${completed}/${items.length}`); - } - - return results; - } - - /** - * Batch ensure Keycloak users for all profiles - * Processes all rows in Profile and ProfileEmployee tables - * - * @returns Object with total, success, failed counts and details - */ - async batchEnsureKeycloakUsers(): Promise<{ - total: number; - created: number; - verified: number; - skipped: number; - failed: number; - details: Array<{ - profileId: string; - profileType: string; - action: string; - keycloakUserId?: string; - error?: string; - }>; - }> { - const result = { - total: 0, - created: 0, - verified: 0, - skipped: 0, - failed: 0, - details: [] as Array<{ - profileId: string; - profileType: string; - action: string; - keycloakUserId?: string; - error?: string; - }>, - }; - - try { - // Get all profiles from Profile table (ข้าราชการ) - const profiles = await this.profileRepo.find({ where: { isLeave: false } }); // Only active profiles - - // Get all profiles from ProfileEmployee table (ลูกจ้าง) - const profileEmployees = await this.profileEmployeeRepo.find({ where: { isLeave: false } }); // Only active profiles - - const allProfiles = [ - ...profiles.map((p) => ({ profile: p, type: "PROFILE" as const })), - ...profileEmployees.map((p) => ({ profile: p, type: "PROFILE_EMPLOYEE" as const })), - ]; - - result.total = allProfiles.length; - - // Process in parallel with concurrency limit - const CONCURRENCY_LIMIT = 5; // Adjust based on environment - - const processedResults = await this.processInParallel( - allProfiles, - CONCURRENCY_LIMIT, - async ({ profile, type }) => { - const ensureResult = await this.ensureKeycloakUser(profile.id, type); - - // Update counters - switch (ensureResult.action) { - case "created": - result.created++; - break; - case "verified": - result.verified++; - break; - case "skipped": - result.skipped++; - break; - case "error": - result.failed++; - break; - } - - return { - profileId: profile.id, - profileType: type, - action: ensureResult.action, - keycloakUserId: ensureResult.keycloakUserId, - error: ensureResult.error, - }; - }, - ); - - // Separate results from errors - for (const resultItem of processedResults) { - if ("error" in resultItem) { - result.failed++; - result.details.push({ - profileId: "unknown", - profileType: "unknown", - action: "error", - error: JSON.stringify(resultItem.error), - }); - } else { - result.details.push(resultItem); - } - } - - console.log( - `Batch ensure Keycloak users completed: total=${result.total}, created=${result.created}, verified=${result.verified}, skipped=${result.skipped}, failed=${result.failed}`, - ); - } catch (error) { - console.error("Error in batch ensure Keycloak users:", error); - } - - return result; - } - - /** - * Clear orphaned Keycloak users - * Deletes users in Keycloak that are not referenced in Profile or ProfileEmployee tables - * - * @param skipUsernames - Array of usernames to skip (e.g., ['super_admin']) - * @returns Object with counts and details - */ - async clearOrphanedKeycloakUsers(skipUsernames: string[] = []): Promise<{ - totalInKeycloak: number; - totalInDatabase: number; - orphanedCount: number; - deleted: number; - skipped: number; - failed: number; - details: Array<{ - keycloakUserId: string; - username: string; - action: "deleted" | "skipped" | "error"; - error?: string; - }>; - }> { - const result = { - totalInKeycloak: 0, - totalInDatabase: 0, - orphanedCount: 0, - deleted: 0, - skipped: 0, - failed: 0, - details: [] as Array<{ - keycloakUserId: string; - username: string; - action: "deleted" | "skipped" | "error"; - error?: string; - }>, - }; - - try { - // Get all keycloak IDs from database (Profile + ProfileEmployee) - const profiles = await this.profileRepo - .createQueryBuilder("p") - .where("p.keycloak IS NOT NULL") - .andWhere("p.keycloak != :empty", { empty: "" }) - .getMany(); - - const profileEmployees = await this.profileEmployeeRepo - .createQueryBuilder("pe") - .where("pe.keycloak IS NOT NULL") - .andWhere("pe.keycloak != :empty", { empty: "" }) - .getMany(); - - // Create a Set of all keycloak IDs in database for O(1) lookup - const databaseKeycloakIds = new Set(); - for (const p of profiles) { - if (p.keycloak) databaseKeycloakIds.add(p.keycloak); - } - for (const pe of profileEmployees) { - if (pe.keycloak) databaseKeycloakIds.add(pe.keycloak); - } - - result.totalInDatabase = databaseKeycloakIds.size; - - // Get all users from Keycloak with pagination to avoid response size limits - const keycloakUsers = await getAllUsersPaginated(); - if (!keycloakUsers || typeof keycloakUsers !== "object") { - throw new Error("Failed to get users from Keycloak"); - } - - result.totalInKeycloak = keycloakUsers.length; - - // Find orphaned users (in Keycloak but not in database) - const orphanedUsers = keycloakUsers.filter((user: any) => !databaseKeycloakIds.has(user.id)); - result.orphanedCount = orphanedUsers.length; - - // Delete orphaned users (skip protected ones) - for (const user of orphanedUsers) { - const username = user.username; - const userId = user.id; - - // Check if user should be skipped - if (skipUsernames.includes(username)) { - result.skipped++; - result.details.push({ - keycloakUserId: userId, - username, - action: "skipped", - }); - continue; - } - - // Delete user from Keycloak - try { - const deleteSuccess = await deleteUser(userId); - if (deleteSuccess) { - result.deleted++; - result.details.push({ - keycloakUserId: userId, - username, - action: "deleted", - }); - } else { - result.failed++; - result.details.push({ - keycloakUserId: userId, - username, - action: "error", - error: "Failed to delete user from Keycloak", - }); - } - } catch (error: any) { - result.failed++; - result.details.push({ - keycloakUserId: userId, - username, - action: "error", - error: error.message || "Unknown error", - }); - } - } - - console.log( - `Clear orphaned Keycloak users completed: totalInKeycloak=${result.totalInKeycloak}, totalInDatabase=${result.totalInDatabase}, orphaned=${result.orphanedCount}, deleted=${result.deleted}, skipped=${result.skipped}, failed=${result.failed}`, - ); - } catch (error) { - console.error("Error in clear orphaned Keycloak users:", error); - throw error; - } - - return result; - } -}