add api sync-missing-emptype
This commit is contained in:
parent
b5fb2346ab
commit
cc85baacdc
2 changed files with 294 additions and 0 deletions
|
|
@ -315,4 +315,81 @@ export class KeycloakSyncController extends Controller {
|
||||||
...result,
|
...result,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync profiles with missing empType for a specific month (Admin only)
|
||||||
|
*
|
||||||
|
* @summary Find profiles updated in specified month with missing empType in Keycloak and sync them (ADMIN)
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* This endpoint will:
|
||||||
|
* - List profiles from Profile table where lastUpdatedAt falls within the specified month
|
||||||
|
* - For each profile, check Keycloak if empType attribute is empty/null
|
||||||
|
* - If empType is empty, sync the profile using existing sync logic
|
||||||
|
* - Return summary of sync results
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Dry run mode (dryRun=true) to check without syncing
|
||||||
|
* - Configurable concurrency for parallel processing
|
||||||
|
* - Rate limiting to avoid overwhelming Keycloak
|
||||||
|
* - Detailed error reporting
|
||||||
|
* - Idempotent (can be safely re-run)
|
||||||
|
*
|
||||||
|
* @param {request} request Request body containing month parameter
|
||||||
|
* @param dryRun - If true, only check without syncing (default: false)
|
||||||
|
* @param concurrency - Number of parallel operations (default: 5)
|
||||||
|
* @param rateLimit - Requests per second limit (default: 10)
|
||||||
|
*/
|
||||||
|
@Post("sync-missing-emptype")
|
||||||
|
@Response<HttpError>(HttpStatus.BAD_REQUEST, "Invalid month format")
|
||||||
|
@Response<HttpError>(HttpStatus.INTERNAL_SERVER_ERROR, "Sync operation failed")
|
||||||
|
async syncMissingEmpType(
|
||||||
|
@Body() request: {
|
||||||
|
month: string;
|
||||||
|
profileType?: "PROFILE" | "PROFILE_EMPLOYEE";
|
||||||
|
},
|
||||||
|
@Query() dryRun: boolean = false,
|
||||||
|
@Query() concurrency: number = 5,
|
||||||
|
@Query() rateLimit: number = 10,
|
||||||
|
) {
|
||||||
|
const { month, profileType = "PROFILE" } = request;
|
||||||
|
|
||||||
|
// Validate month format (YYYY-MM)
|
||||||
|
const monthRegex = /^\d{4}-\d{2}$/;
|
||||||
|
if (!monthRegex.test(month)) {
|
||||||
|
throw new HttpError(HttpStatus.BAD_REQUEST, "รูปแบบเดือนไม่ถูกต้อง ต้องเป็น YYYY-MM");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate profileType
|
||||||
|
if (!["PROFILE", "PROFILE_EMPLOYEE"].includes(profileType)) {
|
||||||
|
throw new HttpError(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"profileType ต้องเป็น PROFILE หรือ PROFILE_EMPLOYEE เท่านั้น",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate concurrency
|
||||||
|
if (concurrency < 1 || concurrency > 20) {
|
||||||
|
throw new HttpError(HttpStatus.BAD_REQUEST, "concurrency ต้องอยู่ระหว่าง 1 ถึง 20");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rateLimit
|
||||||
|
if (rateLimit < 1 || rateLimit > 50) {
|
||||||
|
throw new HttpError(HttpStatus.BAD_REQUEST, "rateLimit ต้องอยู่ระหว่าง 1 ถึง 50");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute sync
|
||||||
|
const result = await this.keycloakAttributeService.syncMissingEmpTypeByMonth({
|
||||||
|
month,
|
||||||
|
profileType,
|
||||||
|
dryRun,
|
||||||
|
concurrency,
|
||||||
|
rateLimit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new HttpSuccess({
|
||||||
|
message: `Sync ${dryRun ? "check " : ""}เสร็จสิ้น`,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,223 @@ export class KeycloakAttributeService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Keycloak user has empty/null empType attribute
|
||||||
|
* @param keycloakUserId - Keycloak user ID
|
||||||
|
* @returns Object with isEmpty flag and currentEmpType value
|
||||||
|
*/
|
||||||
|
async checkEmpTypeEmpty(keycloakUserId: string): Promise<{
|
||||||
|
isEmpty: boolean;
|
||||||
|
currentEmpType?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const user = await getUser(keycloakUserId);
|
||||||
|
|
||||||
|
if (!user || !user.attributes) {
|
||||||
|
return { isEmpty: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const empType = user.attributes.empType?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEmpty: !empType || empType.trim() === "",
|
||||||
|
currentEmpType: empType || "",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[checkEmpTypeEmpty] Error for user ${keycloakUserId}:`, error);
|
||||||
|
return { isEmpty: true }; // Assume empty on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync profiles with missing empType for a specific month
|
||||||
|
* @param options - Sync configuration
|
||||||
|
* @returns Sync results summary
|
||||||
|
*/
|
||||||
|
async syncMissingEmpTypeByMonth(options: {
|
||||||
|
month: string; // "YYYY-MM" format
|
||||||
|
profileType?: "PROFILE" | "PROFILE_EMPLOYEE";
|
||||||
|
dryRun?: boolean;
|
||||||
|
concurrency?: number;
|
||||||
|
rateLimit?: number;
|
||||||
|
}): Promise<{
|
||||||
|
month: string;
|
||||||
|
profileType: string;
|
||||||
|
totalProfiles: number;
|
||||||
|
profilesChecked: number;
|
||||||
|
missingEmpType: number;
|
||||||
|
syncSuccess: number;
|
||||||
|
syncFailed: number;
|
||||||
|
skipped: number;
|
||||||
|
executionTime: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
}> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const {
|
||||||
|
month,
|
||||||
|
profileType = "PROFILE",
|
||||||
|
dryRun = false,
|
||||||
|
concurrency = 5,
|
||||||
|
rateLimit = 10,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
month,
|
||||||
|
profileType,
|
||||||
|
totalProfiles: 0,
|
||||||
|
profilesChecked: 0,
|
||||||
|
missingEmpType: 0,
|
||||||
|
syncSuccess: 0,
|
||||||
|
syncFailed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
executionTime: "",
|
||||||
|
dryRun,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rateLimiter: RateLimiter | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse month (YYYY-MM) to date range
|
||||||
|
const [year, monthNum] = month.split("-").map(Number);
|
||||||
|
const startDate = new Date(Date.UTC(year, monthNum - 1, 1, 0, 0, 0));
|
||||||
|
const endDate = new Date(Date.UTC(year, monthNum, 0, 23, 59, 59, 999));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[syncMissingEmpTypeByMonth] Processing ${profileType} for ${month} (${startDate.toISOString()} to ${endDate.toISOString()})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize rate limiter if rate limiting is enabled
|
||||||
|
if (rateLimit && rateLimit > 0) {
|
||||||
|
rateLimiter = new RateLimiter(rateLimit);
|
||||||
|
console.log(`[syncMissingEmpTypeByMonth] Rate limiting enabled: ${rateLimit} requests/second`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select repository based on profile type
|
||||||
|
const repo =
|
||||||
|
profileType === "PROFILE" ? this.profileRepo : this.profileEmployeeRepo;
|
||||||
|
|
||||||
|
// Query profiles updated within the month
|
||||||
|
const profiles = await repo
|
||||||
|
.createQueryBuilder("p")
|
||||||
|
.where("p.keycloak IS NOT NULL")
|
||||||
|
.andWhere("p.keycloak != :empty", { empty: "" })
|
||||||
|
.andWhere("p.lastUpdatedAt BETWEEN :start AND :end", {
|
||||||
|
start: startDate,
|
||||||
|
end: endDate,
|
||||||
|
})
|
||||||
|
.orderBy("p.lastUpdatedAt", "ASC")
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
result.totalProfiles = profiles.length;
|
||||||
|
console.log(`[syncMissingEmpTypeByMonth] Found ${profiles.length} profiles to check`);
|
||||||
|
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
result.executionTime = `${((Date.now() - startTime) / 1000).toFixed(2)}s`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process profiles in parallel with concurrency limit
|
||||||
|
for (let i = 0; i < profiles.length; i += concurrency) {
|
||||||
|
const batch = profiles.slice(i, i + concurrency);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(async (profile) => {
|
||||||
|
// Apply rate limiting if enabled
|
||||||
|
if (rateLimiter) {
|
||||||
|
await rateLimiter.throttle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const keycloakUserId = profile.keycloak;
|
||||||
|
if (!keycloakUserId) {
|
||||||
|
return {
|
||||||
|
profileId: profile.id,
|
||||||
|
status: "skipped" as const,
|
||||||
|
reason: "No keycloak ID",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if empType is empty in Keycloak
|
||||||
|
const { isEmpty, currentEmpType } =
|
||||||
|
await this.checkEmpTypeEmpty(keycloakUserId);
|
||||||
|
|
||||||
|
result.profilesChecked++;
|
||||||
|
|
||||||
|
if (!isEmpty) {
|
||||||
|
result.skipped++;
|
||||||
|
return {
|
||||||
|
profileId: profile.id,
|
||||||
|
status: "skipped" as const,
|
||||||
|
reason: "empType already exists",
|
||||||
|
empType: currentEmpType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
result.missingEmpType++;
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
profileId: profile.id,
|
||||||
|
status: "skipped" as const,
|
||||||
|
reason: "dry run",
|
||||||
|
wouldSync: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the profile
|
||||||
|
const success = await withRetry(
|
||||||
|
async () =>
|
||||||
|
this.syncOnOrganizationChange(profile.id, profileType),
|
||||||
|
3, // maxRetries
|
||||||
|
1000, // baseDelay
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
result.syncSuccess++;
|
||||||
|
return {
|
||||||
|
profileId: profile.id,
|
||||||
|
status: "synced" as const,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
result.syncFailed++;
|
||||||
|
return {
|
||||||
|
profileId: profile.id,
|
||||||
|
status: "failed" as const,
|
||||||
|
reason: "Sync returned false",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
result.syncFailed++;
|
||||||
|
return {
|
||||||
|
profileId: profile.id,
|
||||||
|
status: "failed" as const,
|
||||||
|
reason: error.message || "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log progress every 50 profiles
|
||||||
|
const completed = Math.min(i + concurrency, profiles.length);
|
||||||
|
if (completed % 50 === 0 || completed === profiles.length) {
|
||||||
|
console.log(
|
||||||
|
`[syncMissingEmpTypeByMonth] Progress: ${completed}/${profiles.length} profiles processed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.executionTime = `${((Date.now() - startTime) / 1000).toFixed(2)}s`;
|
||||||
|
console.log(
|
||||||
|
`[syncMissingEmpTypeByMonth] Completed: total=${result.totalProfiles}, checked=${result.profilesChecked}, missing=${result.missingEmpType}, synced=${result.syncSuccess}, failed=${result.syncFailed}, skipped=${result.skipped}, elapsed=${result.executionTime}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[syncMissingEmpTypeByMonth] Error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear org DNA attributes in Keycloak for given profiles
|
* Clear org DNA attributes in Keycloak for given profiles
|
||||||
* Sets all org DNA fields to empty strings
|
* Sets all org DNA fields to empty strings
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue