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, }); } /** * Clear org DNA attributes for profiles (Admin only) * * @summary Clear org DNA attributes in Keycloak for given profiles (ADMIN) * * @description * This endpoint will: * - Clear all org DNA fields (orgRootDnaId, orgChild1-4DnaId) by setting them to empty strings * - Use when an employee leaves their position (current_holderId becomes null) * * @param {request} request Request body containing profileIds array and profileType */ @Post("clear-org-dna") async clearOrgDna( @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 = await this.keycloakAttributeService.clearOrgDnaAttributes( profileIds, profileType, ); return new HttpSuccess({ message: "Clear org DNA attributes เสร็จสิ้น", ...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. * * Features: * - Resume from checkpoint after failures (use resume=true) * - Automatic retry with exponential backoff * - Rate limiting to avoid overwhelming Keycloak * - Progress tracking and persistence * * @param resume - Resume from last checkpoint (default: false) * @param maxRetries - Maximum retry attempts for failed operations (default: 3) * @param rateLimit - Requests per second rate limit (default: 10) * @param clearProgress - Clear existing progress and start fresh (default: false) */ @Post("sync-all") async syncAll( @Query() resume: boolean = false, @Query() maxRetries: number = 3, @Query() rateLimit: number = 10, @Query() clearProgress: boolean = false, ) { const result = await this.keycloakAttributeService.batchSyncUsers({ resume, maxRetries, rateLimit, clearProgress, }); return new HttpSuccess({ message: "Batch sync เสร็จสิ้น", total: result.total, success: result.success, failed: result.failed, details: result.details, resumed: result.resumed, }); } /** * 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, }); } /** * Sync profiles with missing empType for a specific month (Admin only) * * @summary Find profiles updated in specified month with missing empType in Keycloak and sync them (ADMIN) * * @description * This endpoint will: * - List profiles from Profile table where lastUpdatedAt falls within the specified month * - For each profile, check Keycloak if empType attribute is empty/null * - If empType is empty, sync the profile using existing sync logic * - Return summary of sync results * * Features: * - Dry run mode (dryRun=true) to check without syncing * - Configurable concurrency for parallel processing * - Rate limiting to avoid overwhelming Keycloak * - Detailed error reporting * - Idempotent (can be safely re-run) * * @param {request} request Request body containing month parameter * @param dryRun - If true, only check without syncing (default: false) * @param concurrency - Number of parallel operations (default: 5) * @param rateLimit - Requests per second limit (default: 10) */ @Post("sync-missing-emptype") @Response(HttpStatus.BAD_REQUEST, "Invalid month format") @Response(HttpStatus.INTERNAL_SERVER_ERROR, "Sync operation failed") async syncMissingEmpType( @Body() request: { month: string; profileType?: "PROFILE" | "PROFILE_EMPLOYEE"; }, @Query() dryRun: boolean = false, @Query() concurrency: number = 5, @Query() rateLimit: number = 10, ) { const { month, profileType = "PROFILE" } = request; // Validate month format (YYYY-MM) const monthRegex = /^\d{4}-\d{2}$/; if (!monthRegex.test(month)) { throw new HttpError(HttpStatus.BAD_REQUEST, "รูปแบบเดือนไม่ถูกต้อง ต้องเป็น YYYY-MM"); } // Validate profileType if (!["PROFILE", "PROFILE_EMPLOYEE"].includes(profileType)) { throw new HttpError( HttpStatus.BAD_REQUEST, "profileType ต้องเป็น PROFILE หรือ PROFILE_EMPLOYEE เท่านั้น", ); } // Validate concurrency if (concurrency < 1 || concurrency > 20) { throw new HttpError(HttpStatus.BAD_REQUEST, "concurrency ต้องอยู่ระหว่าง 1 ถึง 20"); } // Validate rateLimit if (rateLimit < 1 || rateLimit > 50) { throw new HttpError(HttpStatus.BAD_REQUEST, "rateLimit ต้องอยู่ระหว่าง 1 ถึง 50"); } // Execute sync const result = await this.keycloakAttributeService.syncMissingEmpTypeByMonth({ month, profileType, dryRun, concurrency, rateLimit, }); return new HttpSuccess({ message: `Sync ${dryRun ? "check " : ""}เสร็จสิ้น`, ...result, }); } }