Revert "Merge branch 'feat/keyloak-token-data' into develop"
All checks were successful
Build & Deploy on Dev / build (push) Successful in 2m21s
All checks were successful
Build & Deploy on Dev / build (push) Successful in 2m21s
This reverts commit1b3806c6f7, reversing changes made to79dbba2c89.
This commit is contained in:
parent
30fd08fc85
commit
1c629cc6e0
13 changed files with 33 additions and 2582 deletions
137
CLAUDE.md
137
CLAUDE.md
|
|
@ -1,137 +0,0 @@
|
||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
This is **bma-ehr-organization** - an HRMS (Human Resource Management System) API backend for a Thai healthcare organization. It manages personnel data, organizational structure, positions, and employee profiles for an Electronic Health Record (EHR) system.
|
|
||||||
|
|
||||||
- **Type**: RESTful API backend with WebSocket support
|
|
||||||
- **Language**: TypeScript/Node.js
|
|
||||||
- **Database**: MySQL with TypeORM
|
|
||||||
- **API Framework**: TSOA (TypeScript OpenAPI) with auto-generated routes and Swagger docs
|
|
||||||
|
|
||||||
## Common Commands
|
|
||||||
|
|
||||||
### Development
|
|
||||||
```bash
|
|
||||||
npm run dev # Start development server with hot-reload (nodemon)
|
|
||||||
npm run build # Build for production (runs tsoa spec-and-routes && tsc)
|
|
||||||
npm start # Run production build (node ./dist/app.js)
|
|
||||||
npm run format # Format code with Prettier
|
|
||||||
npm run check # Type check without emitting (tsc --noEmit)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Migrations
|
|
||||||
|
|
||||||
**CRITICAL**: After generating any migration, you MUST run the cleanup script to remove FK/idx lines:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate new migration (include descriptive name)
|
|
||||||
npm run migration:generate src/migration/update_table_0811202s
|
|
||||||
|
|
||||||
# CLEANUP: Remove FK_/idx_ lines from generated migration
|
|
||||||
node scripts/clean-migration-fk-idx.js
|
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
npm run migration:run
|
|
||||||
```
|
|
||||||
|
|
||||||
The cleanup script replaces lines containing `FK_` or `idx_` with `// removed FK_/idx_ auto-cleanup`. This is required because TypeORM generates foreign key and index constraints that must be manually removed.
|
|
||||||
|
|
||||||
### Local GitHub Actions Testing
|
|
||||||
```bash
|
|
||||||
# Test release workflow locally using act
|
|
||||||
act workflow_dispatch -W .github/workflows/release.yaml \
|
|
||||||
--input IMAGE_VER=latest \
|
|
||||||
-s DOCKER_USER=admin \
|
|
||||||
-s DOCKER_PASS=FPTadmin2357 \
|
|
||||||
-s SSH_PASSWORD=FPTadmin2357
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Application Entry Point
|
|
||||||
`src/app.ts` - Initializes the application:
|
|
||||||
1. Database connection (TypeORM MySQL)
|
|
||||||
2. In-memory caches (LogMemoryStore, OrgStructureCache with 30min TTL)
|
|
||||||
3. WebSocket server for real-time updates
|
|
||||||
4. Express middleware and TSOA routes
|
|
||||||
5. Cronjobs (scheduled tasks)
|
|
||||||
6. RabbitMQ message queue consumer
|
|
||||||
|
|
||||||
### Controllers (`src/controllers/`)
|
|
||||||
Handle HTTP requests. Main controllers include:
|
|
||||||
- `OrganizationController` - Organizational structure management
|
|
||||||
- `CommandController` - Workflow and command processing
|
|
||||||
- `ProfileSalaryController` - Salary and tenure management
|
|
||||||
- Controllers for positions, employees, auth, etc.
|
|
||||||
|
|
||||||
Cronjob logic is embedded in controllers (not separate services).
|
|
||||||
|
|
||||||
### Services (`src/services/`)
|
|
||||||
Business logic and external integrations:
|
|
||||||
- `OrganizationService.ts` - Core organizational logic
|
|
||||||
- `rabbitmq.ts` - RabbitMQ consumer for async processing
|
|
||||||
- `webSocket.ts` - Real-time updates to clients
|
|
||||||
|
|
||||||
### Entities (`src/entities/`)
|
|
||||||
TypeORM database models for MySQL. Key entities:
|
|
||||||
- Organization hierarchy (OrgRoot, OrgChild1-4)
|
|
||||||
- Position management (Position, PosType, PosLevel, PosExecutive)
|
|
||||||
- Employee profiles and related data
|
|
||||||
- Command/Workflow entities
|
|
||||||
|
|
||||||
### Middlewares (`src/middlewares/`)
|
|
||||||
- `auth.ts` - Keycloak Bearer token authentication
|
|
||||||
- `authWebService.ts` - API key authentication (`X-API-Key` header)
|
|
||||||
- `role.ts` - Role-based authorization
|
|
||||||
- `logs.ts` - Request logging
|
|
||||||
- `error.ts` - Global error handling
|
|
||||||
- `user.ts` - User context extraction
|
|
||||||
|
|
||||||
### TSOA Configuration
|
|
||||||
`src/routes.ts` is auto-generated by TSOA from controller decorators. Regenerated by `npm run build`.
|
|
||||||
|
|
||||||
- Swagger docs available at `/api-docs`
|
|
||||||
- Dual authentication: Keycloak bearer tokens and API keys
|
|
||||||
- API tags organized by domain (Organization, Position, Employee, etc.)
|
|
||||||
|
|
||||||
## Scheduled Cronjobs
|
|
||||||
|
|
||||||
All times in Bangkok timezone (UTC+7):
|
|
||||||
|
|
||||||
| Schedule | Task | Controller |
|
|
||||||
|----------|------|------------|
|
|
||||||
| `0 0 3 * * *` | Daily revision processing | `OrganizationController.cronjobRevision()` |
|
|
||||||
| `0 0 2 * * *` | Daily command processing | `CommandController.cronjobCommand()` |
|
|
||||||
| `0 0 1 10 *` | Monthly retirement status update (10th of month) | `CommandController.cronjobUpdateRetirementStatus()` |
|
|
||||||
| `0 0 0 * * *` | Daily tenure updates | `ProfileSalaryController` (multiple tenure methods) |
|
|
||||||
|
|
||||||
## External Dependencies
|
|
||||||
|
|
||||||
- **Keycloak** - Authentication and authorization
|
|
||||||
- **RabbitMQ** - Message queue for async operations
|
|
||||||
- **WebSocket** (Socket.IO) - Real-time updates
|
|
||||||
- **Elasticsearch** - Logging
|
|
||||||
- **Redis** - Caching layer
|
|
||||||
- **TypeORM** - Database ORM
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- **Prettier**: 2 spaces, 100 char width, trailing commas
|
|
||||||
- **TypeScript**: Strict mode enabled
|
|
||||||
- **Comments**: Mixed Thai and English
|
|
||||||
- **Date handling**: Custom `DateSerializer` for local timezone serialization
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
1. **Migration cleanup is mandatory** - TypeORM generates FK and index constraints that break the migration system. Always run `node scripts/clean-migration-fk-idx.js` after `migration:generate`.
|
|
||||||
|
|
||||||
2. **Organization caching** - `OrgStructureCache` provides in-memory caching of org structure (30 min TTL) for performance.
|
|
||||||
|
|
||||||
3. **Graceful shutdown** - Application handles SIGTERM/SIGINT to close database connections, caches, and HTTP server properly.
|
|
||||||
|
|
||||||
4. **Dual auth system** - Most endpoints use Keycloak bearer tokens, but some web service endpoints use `X-API-Key` header authentication.
|
|
||||||
|
|
||||||
5. **Thai localization** - The system is primarily for Thai users; documentation and some content is in Thai, but code is in English.
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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<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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
44
src/app.ts
44
src/app.ts
|
|
@ -15,7 +15,6 @@ import { logMemoryStore } from "./utils/LogMemoryStore";
|
||||||
import { orgStructureCache } from "./utils/OrgStructureCache";
|
import { orgStructureCache } from "./utils/OrgStructureCache";
|
||||||
import { CommandController } from "./controllers/CommandController";
|
import { CommandController } from "./controllers/CommandController";
|
||||||
import { ProfileSalaryController } from "./controllers/ProfileSalaryController";
|
import { ProfileSalaryController } from "./controllers/ProfileSalaryController";
|
||||||
import { ScriptProfileOrgController } from "./controllers/ScriptProfileOrgController";
|
|
||||||
import { DateSerializer } from "./interfaces/date-serializer";
|
import { DateSerializer } from "./interfaces/date-serializer";
|
||||||
|
|
||||||
import { initWebSocket } from "./services/webSocket";
|
import { initWebSocket } from "./services/webSocket";
|
||||||
|
|
@ -53,19 +52,8 @@ async function main() {
|
||||||
const APP_HOST = process.env.APP_HOST || "0.0.0.0";
|
const APP_HOST = process.env.APP_HOST || "0.0.0.0";
|
||||||
const APP_PORT = +(process.env.APP_PORT || 3000);
|
const APP_PORT = +(process.env.APP_PORT || 3000);
|
||||||
|
|
||||||
// Cron job for executing command - every day at 00:30:00
|
const cronTime = "0 0 3 * * *"; // ตั้งเวลาทุกวันเวลา 03:00:00
|
||||||
const cronTime_command = "0 30 0 * * *";
|
// const cronTime = "*/10 * * * * *";
|
||||||
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 * * *";
|
|
||||||
cron.schedule(cronTime, async () => {
|
cron.schedule(cronTime, async () => {
|
||||||
try {
|
try {
|
||||||
const orgController = new OrganizationController();
|
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_command = "0 0 2 * * *";
|
||||||
const cronTime_Oct = "0 0 2 10 *";
|
// 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 () => {
|
cron.schedule(cronTime_Oct, async () => {
|
||||||
try {
|
try {
|
||||||
const commandController = new CommandController();
|
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_Tenure = "0 0 0 * * *";
|
||||||
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 () => {
|
cron.schedule(cronTime_Tenure, async () => {
|
||||||
try {
|
try {
|
||||||
const profileSalaryController = new ProfileSalaryController();
|
const profileSalaryController = new ProfileSalaryController();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,8 +4,8 @@ const KC_URL = process.env.KC_URL;
|
||||||
const KC_REALMS = process.env.KC_REALMS;
|
const KC_REALMS = process.env.KC_REALMS;
|
||||||
const KC_CLIENT_ID = process.env.KC_SERVICE_ACCOUNT_CLIENT_ID;
|
const KC_CLIENT_ID = process.env.KC_SERVICE_ACCOUNT_CLIENT_ID;
|
||||||
const KC_SECRET = process.env.KC_SERVICE_ACCOUNT_SECRET;
|
const KC_SECRET = process.env.KC_SERVICE_ACCOUNT_SECRET;
|
||||||
// const AUTH_ACCOUNT_SECRET = process.env.AUTH_ACCOUNT_SECRET;
|
const AUTH_ACCOUNT_SECRET = process.env.AUTH_ACCOUNT_SECRET;
|
||||||
// const API_KEY = process.env.API_KEY;
|
const API_KEY = process.env.API_KEY;
|
||||||
|
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
let decoded: DecodedJwt | null = null;
|
let decoded: DecodedJwt | null = null;
|
||||||
|
|
@ -165,119 +165,16 @@ export async function getUserList(first = "", max = "", search = "") {
|
||||||
|
|
||||||
if (!res) return false;
|
if (!res) return false;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorText = await res.text();
|
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
|
||||||
return Boolean(console.error("Keycloak Error Response: ", errorText));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get raw text first to handle potential JSON parsing errors
|
return ((await res.json()) as any[]).map((v: Record<string, string>) => ({
|
||||||
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,
|
id: v.id,
|
||||||
username: v.username,
|
username: v.username,
|
||||||
firstName: v.firstName,
|
firstName: v.firstName,
|
||||||
lastName: v.lastName,
|
lastName: v.lastName,
|
||||||
email: v.email,
|
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) return false;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorText = await res.text();
|
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
|
||||||
return Boolean(console.error("Keycloak Error Response: ", errorText));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get raw text first to handle potential JSON parsing errors
|
return ((await res.json()) as any[]).map((v: Record<string, string>) => ({
|
||||||
const rawText = await res.text();
|
id: v.id,
|
||||||
|
username: v.username,
|
||||||
try {
|
firstName: v.firstName,
|
||||||
const data = JSON.parse(rawText) as any[];
|
lastName: v.lastName,
|
||||||
return data.map((v: Record<string, string>) => ({
|
email: v.email,
|
||||||
id: v.id,
|
enabled: v.enabled,
|
||||||
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[] = []) {
|
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 {
|
||||||
return {
|
// id: data.id,
|
||||||
id: data.id,
|
// name: data.name,
|
||||||
name: data.name,
|
// };
|
||||||
description: data.description,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -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<string, string[]>,
|
|
||||||
): Promise<boolean> {
|
|
||||||
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
|
// Function to reset password
|
||||||
export async function resetPassword(username: string) {
|
export async function resetPassword(username: string) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -75,16 +75,6 @@ export async function expressAuthentication(
|
||||||
request.app.locals.logData.userName = payload.name;
|
request.app.locals.logData.userName = payload.name;
|
||||||
request.app.locals.logData.user = payload.preferred_username;
|
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;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,6 @@ export type RequestWithUser = Request & {
|
||||||
preferred_username: string;
|
preferred_username: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string[];
|
role: string[];
|
||||||
profileId?: string;
|
|
||||||
orgRootDnaId?: string;
|
|
||||||
orgChild1DnaId?: string;
|
|
||||||
orgChild2DnaId?: string;
|
|
||||||
orgChild3DnaId?: string;
|
|
||||||
orgChild4DnaId?: string;
|
|
||||||
empType?: string;
|
|
||||||
prefix?: string;
|
|
||||||
scope?: string;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<UserProfileAttributes> {
|
|
||||||
// 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<UserProfileAttributes> {
|
|
||||||
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<boolean> {
|
|
||||||
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<string, string[]> = {
|
|
||||||
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<boolean> {
|
|
||||||
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<UserProfileAttributes | null> {
|
|
||||||
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<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue