completed sync script update keycloak

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

View file

@ -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

269
scripts/assign-user-role.ts Normal file
View file

@ -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<UserWithKeycloak[]> {
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<AssignResult> {
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);

View file

@ -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);

91
scripts/ensure-users.ts Normal file
View file

@ -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);

93
scripts/sync-all.ts Normal file
View file

@ -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);

View file

@ -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<number> {
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<SyncResult> {
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"

View file

@ -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();

View file

@ -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,
});
}
}

View file

@ -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<string, { total: number; success: number; failed: number }>,
};
// 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<string, string[]> {
const grouped: Record<string, string[]> = {
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<string, string[]> = {};
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<T>(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;
}
}

View file

@ -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<string, string>) => ({
// 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<string, string>) => ({
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<string, string>) => ({
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<string, string>) => ({
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: {

View file

@ -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;
}

View file

@ -16,6 +16,7 @@ export type RequestWithUser = Request & {
orgChild3DnaId?: string;
orgChild4DnaId?: string;
empType?: string;
prefix?: string;
scope?: string;
};
};

View file

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