From 659e06a08d9f7e03ad50bfddf4c443e69fe02187 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Fri, 30 Jan 2026 13:35:58 +0700 Subject: [PATCH] Add cancellation token support and extend timeout to 30 minutes for external API calls --- .../Repositories/GenericRepository.cs | 29 +++++-- .../TimeAttendants/DutyTimeRepository.cs | 7 +- .../TimeAttendants/UserDutyTimeRepository.cs | 7 +- .../TimeAttendants/UserTimeStampRepository.cs | 8 +- .../Repositories/UserProfileRepository.cs | 4 +- BMA.EHR.Leave/Controllers/LeaveController.cs | 86 ++++++++----------- dotnet_leave_test.js | 11 +-- 7 files changed, 76 insertions(+), 76 deletions(-) diff --git a/BMA.EHR.Application/Repositories/GenericRepository.cs b/BMA.EHR.Application/Repositories/GenericRepository.cs index fcc2aef0..805b88ba 100644 --- a/BMA.EHR.Application/Repositories/GenericRepository.cs +++ b/BMA.EHR.Application/Repositories/GenericRepository.cs @@ -53,15 +53,18 @@ namespace BMA.EHR.Application.Repositories #region " For Call External API " - protected async Task GetExternalAPIAsync(string apiPath, string accessToken, string apiKey) + protected async Task GetExternalAPIAsync(string apiPath, string accessToken, string apiKey, CancellationToken cancellationToken = default) { try { + // กำหนด timeout เป็น 30 นาที + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(30)); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); using (var client = new HttpClient()) { client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Replace("Bearer ", "")); client.DefaultRequestHeaders.Add("api-key", apiKey); - var _res = await client.GetAsync(apiPath); + var _res = await client.GetAsync(apiPath,cancellationToken: combinedCts.Token); if (_res.IsSuccessStatusCode) { var _result = await _res.Content.ReadAsStringAsync(); @@ -77,10 +80,13 @@ namespace BMA.EHR.Application.Repositories } } - protected async Task SendExternalAPIAsync(HttpMethod method, string apiPath, string accessToken, object? body, string apiKey) + protected async Task SendExternalAPIAsync(HttpMethod method, string apiPath, string accessToken, object? body, string apiKey, CancellationToken cancellationToken = default) { try { + // กำหนด timeout เป็น 30 นาที + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(30)); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); // สร้าง request message var request = new HttpRequestMessage(method, apiPath); @@ -92,7 +98,7 @@ namespace BMA.EHR.Application.Repositories { client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Replace("Bearer ", "")); client.DefaultRequestHeaders.Add("api-key", apiKey); - var _res = await client.SendAsync(request); + var _res = await client.SendAsync(request, combinedCts.Token); if (_res.IsSuccessStatusCode) { var _result = await _res.Content.ReadAsStringAsync(); @@ -109,11 +115,13 @@ namespace BMA.EHR.Application.Repositories } - protected async Task PostExternalAPIAsync(string apiPath, string accessToken, object? body, string apiKey) + protected async Task PostExternalAPIAsync(string apiPath, string accessToken, object? body, string apiKey, CancellationToken cancellationToken = default) { try { - + // กำหนด timeout เป็น 30 นาที + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(30)); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); var json = JsonConvert.SerializeObject(body); var stringContent = new StringContent(json, Encoding.UTF8, "application/json"); //stringContent.Headers.ContentType = new MediaTypeHeaderValue("application/json"); @@ -122,7 +130,7 @@ namespace BMA.EHR.Application.Repositories { client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Replace("Bearer ", "")); client.DefaultRequestHeaders.Add("api-key", apiKey); - var _res = await client.PostAsync(apiPath, stringContent); + var _res = await client.PostAsync(apiPath, stringContent, combinedCts.Token); if (_res.IsSuccessStatusCode) { var _result = await _res.Content.ReadAsStringAsync(); @@ -138,10 +146,13 @@ namespace BMA.EHR.Application.Repositories } } - protected async Task PostExternalAPIBooleanAsync(string apiPath, string accessToken, object? body, string apiKey) + protected async Task PostExternalAPIBooleanAsync(string apiPath, string accessToken, object? body, string apiKey, CancellationToken cancellationToken = default) { try { + // กำหนด timeout เป็น 30 นาที + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(30)); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); var json = JsonConvert.SerializeObject(body); var stringContent = new StringContent(json, UnicodeEncoding.UTF8, "application/json"); stringContent.Headers.ContentType = new MediaTypeHeaderValue("application/json"); @@ -150,7 +161,7 @@ namespace BMA.EHR.Application.Repositories { client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Replace("Bearer ", "")); client.DefaultRequestHeaders.Add("api-key", apiKey); - var _res = await client.PostAsync(apiPath, stringContent); + var _res = await client.PostAsync(apiPath, stringContent, combinedCts.Token); return _res.IsSuccessStatusCode; } } diff --git a/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/DutyTimeRepository.cs b/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/DutyTimeRepository.cs index 2db889ac..e96c35a9 100644 --- a/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/DutyTimeRepository.cs +++ b/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/DutyTimeRepository.cs @@ -61,9 +61,12 @@ namespace BMA.EHR.Application.Repositories.Leaves.TimeAttendants return await _dbContext.Set().Where(x => x.IsActive).ToListAsync(); } - public async Task GetDefaultAsync() + public async Task GetDefaultAsync(CancellationToken cancellationToken = default) { - return await _dbContext.Set().Where(x => x.IsDefault).FirstOrDefaultAsync(); + // กำหนด timeout เป็น 30 นาที + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(30)); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + return await _dbContext.Set().Where(x => x.IsDefault).FirstOrDefaultAsync(combinedCts.Token); } #endregion diff --git a/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/UserDutyTimeRepository.cs b/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/UserDutyTimeRepository.cs index 5ca823d9..b34bc4db 100644 --- a/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/UserDutyTimeRepository.cs +++ b/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/UserDutyTimeRepository.cs @@ -101,14 +101,17 @@ namespace BMA.EHR.Application.Repositories.Leaves.TimeAttendants return data; } - public async Task GetLastEffectRound(Guid profileId, DateTime? effectiveDate = null) + public async Task GetLastEffectRound(Guid profileId, DateTime? effectiveDate = null, CancellationToken cancellationToken = default) { + // กำหนด timeout เป็น 30 นาที + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(30)); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); effectiveDate ??= DateTime.Now; var data = await _dbContext.Set() .Where(x => x.ProfileId == profileId) .Where(x => x.EffectiveDate.Value.Date <= effectiveDate.Value.Date) .OrderByDescending(x => x.EffectiveDate) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(combinedCts.Token); return data; } diff --git a/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/UserTimeStampRepository.cs b/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/UserTimeStampRepository.cs index b7a766e9..2598dace 100644 --- a/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/UserTimeStampRepository.cs +++ b/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/UserTimeStampRepository.cs @@ -74,12 +74,16 @@ namespace BMA.EHR.Application.Repositories.Leaves.TimeAttendants return data; } - public async Task GetLastRecord(Guid keycloakId) + public async Task GetLastRecord(Guid keycloakId, CancellationToken cancellationToken = default) { + // กำหนด timeout เป็น 30 นาที + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(30)); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + var data = await _dbContext.Set() .Where(u => u.KeycloakUserId == keycloakId) .OrderByDescending(u => u.CheckIn) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(combinedCts.Token); return data; } diff --git a/BMA.EHR.Application/Repositories/UserProfileRepository.cs b/BMA.EHR.Application/Repositories/UserProfileRepository.cs index ebf5113f..4e0e986f 100644 --- a/BMA.EHR.Application/Repositories/UserProfileRepository.cs +++ b/BMA.EHR.Application/Repositories/UserProfileRepository.cs @@ -186,14 +186,14 @@ namespace BMA.EHR.Application.Repositories } } - public async Task GetProfileByKeycloakIdNewAsync(Guid keycloakId, string? accessToken) + public async Task GetProfileByKeycloakIdNewAsync(Guid keycloakId, string? accessToken,CancellationToken cancellationToken = default) { try { var apiPath = $"{_configuration["API"]}/org/dotnet/by-keycloak/{keycloakId}"; var apiKey = _configuration["API_KEY"]; - var apiResult = await GetExternalAPIAsync(apiPath, accessToken ?? "", apiKey); + var apiResult = await GetExternalAPIAsync(apiPath, accessToken ?? "", apiKey, cancellationToken); if (apiResult != null) { var raw = JsonConvert.DeserializeObject(apiResult); diff --git a/BMA.EHR.Leave/Controllers/LeaveController.cs b/BMA.EHR.Leave/Controllers/LeaveController.cs index 062ceab1..9819ecd0 100644 --- a/BMA.EHR.Leave/Controllers/LeaveController.cs +++ b/BMA.EHR.Leave/Controllers/LeaveController.cs @@ -428,18 +428,26 @@ namespace BMA.EHR.Leave.Service.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> CheckTimeAsync() + public async Task> CheckTimeAsync(CancellationToken cancellationToken = default) { var userId = UserId == null ? Guid.Empty : Guid.Parse(UserId); - var data = await _userTimeStampRepository.GetLastRecord(userId); + + // Get user's last check-in record and profile in parallel + var dataTask = _userTimeStampRepository.GetLastRecord(userId); + var profileTask = _userProfileRepository.GetProfileByKeycloakIdNewAsync(userId, AccessToken); + var defaultRoundTask = _dutyTimeRepository.GetDefaultAsync(); + + await Task.WhenAll(dataTask, profileTask, defaultRoundTask); + + var data = await dataTask; + var profile = await profileTask; + var getDefaultRound = await defaultRoundTask; - var profile = await _userProfileRepository.GetProfileByKeycloakIdNewAsync(userId, AccessToken); if (profile == null) { throw new Exception(GlobalMessages.DataNotFound); } - var getDefaultRound = await _dutyTimeRepository.GetDefaultAsync(); if (getDefaultRound == null) { return Error("ไม่พบรอบลงเวลา Default", StatusCodes.Status404NotFound); @@ -451,65 +459,43 @@ namespace BMA.EHR.Leave.Service.Controllers var duty = userRound ?? getDefaultRound; - // TODO : รอดุึงรอบที่ผูกกับ user - //var duty = await _dutyTimeRepository.GetDefaultAsync(); - CheckInResultDto ret; - - if (data == null) - { - ret = new CheckInResultDto - { - StartTimeMorning = duty == null ? "00:00" : duty.StartTimeMorning, - EndTimeMorning = duty == null ? "00:00" : duty.EndTimeMorning, - StartTimeAfternoon = duty == null ? "00:00" : duty.StartTimeAfternoon, - EndTimeAfternoon = duty == null ? "00:00" : duty.EndTimeAfternoon, - Description = duty == null ? "-" : duty.Description, - CheckInTime = null, - CheckInId = null, - }; - } - else + // Determine check-in status and data + DateTime? checkInTime = null; + Guid? checkInId = null; + + if (data != null) { if (data.CheckOut != null) { // fix issue SIT ระบบบันทึกเวลาปฏิบัติงาน>>ลงเวลาเข้า-ออกงาน (กรณีลงเวลาออกอีกวัน) #921 - var cur_date = DateTime.Now.Date; - // ถ้า check-in + check-out ไปแล้ว - if (data.CheckIn.Date == cur_date && data.CheckOut.Value.Date == cur_date) + var currentDate = DateTime.Now.Date; + // ถ้า check-in + check-out ไปแล้วในวันเดียวกัน + if (data.CheckIn.Date == currentDate && data.CheckOut.Value.Date == currentDate) { return Error("คุณได้ทำการลงเวลาเข้าและออกเรียบร้อยแล้ว คุณจะสามารถลงเวลาได้อีกครั้งในวันถัดไป"); } - else - { - ret = new CheckInResultDto - { - StartTimeMorning = duty == null ? "00:00" : duty.StartTimeMorning, - EndTimeMorning = duty == null ? "00:00" : duty.EndTimeMorning, - StartTimeAfternoon = duty == null ? "00:00" : duty.StartTimeAfternoon, - EndTimeAfternoon = duty == null ? "00:00" : duty.EndTimeAfternoon, - Description = duty == null ? "-" : duty.Description, - CheckInTime = null, - CheckInId = null, - }; - } + // ถ้า check-out คนละวัน ให้แสดงว่ายังไม่ได้ check-in วันนี้ } else { - ret = new CheckInResultDto - { - StartTimeMorning = duty == null ? "00:00" : duty.StartTimeMorning, - EndTimeMorning = duty == null ? "00:00" : duty.EndTimeMorning, - StartTimeAfternoon = duty == null ? "00:00" : duty.StartTimeAfternoon, - EndTimeAfternoon = duty == null ? "00:00" : duty.EndTimeAfternoon, - Description = duty == null ? "-" : duty.Description, - CheckInTime = data.CheckIn, - CheckInId = data.Id, - }; + // มี check-in แต่ยังไม่ check-out + checkInTime = data.CheckIn; + checkInId = data.Id; } - - } + // Create response DTO (duty is never null here due to fallback logic) + var ret = new CheckInResultDto + { + StartTimeMorning = duty.StartTimeMorning, + EndTimeMorning = duty.EndTimeMorning, + StartTimeAfternoon = duty.StartTimeAfternoon, + EndTimeAfternoon = duty.EndTimeAfternoon, + Description = duty.Description, + CheckInTime = checkInTime, + CheckInId = checkInId, + }; + return Success(ret); } diff --git a/dotnet_leave_test.js b/dotnet_leave_test.js index 5add397d..658848ec 100644 --- a/dotnet_leave_test.js +++ b/dotnet_leave_test.js @@ -31,18 +31,11 @@ export default function () { // ตัวเลือก headers let headers = { "Content-Type": "application/json", - Authorization: - "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ4WTJWUi1FRnZ2TlBzTXMzOXU4b29WQldRTDZtUHdyTkpPaDNrb0pGVGdVIn0.eyJleHAiOjE3NzYyMTkxNjgsImlhdCI6MTc2ODQ0MzE2OCwianRpIjoiZDQxMmI5MWEtZmZhMi00N2JiLTliZDUtZDE5NTdmMDFjYzQyIiwiaXNzIjoiaHR0cHM6Ly9ocm1zLWlkLmJhbmdrb2suZ28udGgvcmVhbG1zL2hybXMiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiYmFmYzU3OTUtYmVmYy00ZDNmLWE0NjEtMzUzM2MzOGE1ZmMxIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiZ2V0dG9rZW4tY2hlY2tpbiIsInNpZCI6IjBkNzdiY2Y5LTE4YWQtNGQyMS1hYjBjLTI4Y2ZiZjUyZGZiNCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cHM6Ly9ocm1zLmJhbmdrb2suZ28udGgiLCJodHRwczovL2hybXMtY2hlY2tpbi5iYW5na29rLmdvLnRoIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJTVVBFUl9BRE1JTiIsInN0b3JhZ2VfbWFuYWdlbWVudCIsIm9mZmxpbmVfYWNjZXNzIiwiU1RBRkYiLCJkZWZhdWx0LXJvbGVzLWhybXMiLCJ1bWFfYXV0aG9yaXphdGlvbiIsIkFETUlOIiwiVVNFUiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoiZW1haWwgb3BlbmlkIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInJvbGUiOlsiU1VQRVJfQURNSU4iLCJzdG9yYWdlX21hbmFnZW1lbnQiLCJvZmZsaW5lX2FjY2VzcyIsIlNUQUZGIiwiZGVmYXVsdC1yb2xlcy1ocm1zIiwidW1hX2F1dGhvcml6YXRpb24iLCJBRE1JTiIsIlVTRVIiXSwibmFtZSI6IuC4p-C4seC4meC5gOC4ieC4peC4tOC4oSDguInguLHguJXguKPguJfguK3guIciLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiIzMTIwMjAwNDI0OTc1IiwiZ2l2ZW5fbmFtZSI6IuC4p-C4seC4meC5gOC4ieC4peC4tOC4oSIsImZhbWlseV9uYW1lIjoi4LiJ4Lix4LiV4Lij4LiX4Lit4LiHIn0.UhMn0NEkymPxMAcb4noZedHCSqXotCyD2RziBtLYHn5OhA9yk1915Rrt9iV4wVaebr74iZ2eZMpBwp8YVy8-3cPXSv9T3vzbXwFP7IeICPCDDf4bOPFEHP5FYow2s9v48qG81wnu01AG7_EL2-CQKh1sBVrCVUUlATlf-P4lT_lHeHOCKNXTmw4V0IWm96ec6pk-jFY3KH2JdRSWR7wq8g-KVxhLOxk_pF72kMwOpdvcr_99byg28zzj6QfeNYXLt61koHXnZppUqytt86mQQgfamv2FNVywCEzbRITUceu2rmJnwQE8ubeoCh4UOsYauUuSKd7RPqvvXxL_Vg__8Q", - //"Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJTT2wwWmFidm9rRzZET3pDZVBtT09Kek5haTdMUldkci1zV3lEYjRELTc0In0.eyJleHAiOjE3Njg4ODAzMjgsImlhdCI6MTc2ODc5MzkyOCwianRpIjoiMDYxODBlMWYtNTQzYy00MjU0LWFmN2QtYWI1NDA5NzFmNWY2IiwiaXNzIjoiaHR0cHM6Ly9ocm1zYmtrLWlkLmNhc2UtY29sbGVjdGlvbi5jb20vcmVhbG1zL2hybXMiLCJhdWQiOlsiYWNjb3VudCIsImdldHRva2VuIl0sInN1YiI6IjQzOWZhMzZkLTZiYzUtNGVmNS05NWFhLWVmMjllNjRkMmU5ZiIsInR5cCI6IkJlYXJlciIsImF6cCI6ImdldHRva2VuIiwic2lkIjoiZGI2YzUxNjItNzZhYS00MmVmLWI0ZDMtYThmOTk2N2NjZWM2IiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyIqIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJTVVBFUl9BRE1JTiIsIm9mZmxpbmVfYWNjZXNzIiwiU1RBRkYiLCJkZWZhdWx0LXJvbGVzLWhybXMiLCJ1bWFfYXV0aG9yaXphdGlvbiIsIkFETUlOIiwiVVNFUiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoiZW1haWwgb3BlbmlkIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInJvbGUiOlsiU1VQRVJfQURNSU4iLCJvZmZsaW5lX2FjY2VzcyIsIlNUQUZGIiwiZGVmYXVsdC1yb2xlcy1ocm1zIiwidW1hX2F1dGhvcml6YXRpb24iLCJBRE1JTiIsIlVTRVIiXSwibmFtZSI6IuC4p-C4seC4meC5gOC4ieC4peC4tOC4oSDguInguLHguJXguKPguJfguK3guIciLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiIzMTIwMjAwNDI0OTc1IiwiZ2l2ZW5fbmFtZSI6IuC4p-C4seC4meC5gOC4ieC4peC4tOC4oSIsImZhbWlseV9uYW1lIjoi4LiJ4Lix4LiV4Lij4LiX4Lit4LiHIn0.fHdMzpHMD4JcbzYnUrfM473FSXka2Z4lz_S3HI2c-dPXfO5ATpijqsi12C6-ExE0RJRXUK671erMuyVXL6u2qj-FvdliBL3ubKy4J3jIT3svkcZxZL2ib16dRg375dITefvqd-J4vw6MR4bq8YAGPbqRIy6BQ2pdEiZgNiwUUihHAFwZlVER1lNbaqlbL6vk_L4k-g25DBVnDr756BFvrw7zEDbawkKZ31EZF5_DYk4RWej0wvWrGHQWLw-RyzYVSBB_AooqHkncHn_CwLBGC5juOEfFO4a2ThuKwoxYCstjtBj-zmjpHFs-Hh3CBTWJCGFcKst1Ey28StlKtNkLiw", + Authorization: "Bearer {Token}", }; // ส่ง GET request - let response = http.get( - //"https://bma-hrms.bangkok.go.th/api/v1/leave/fake-check-in", - //"https://hrmsbkk.case-collection.com/api/v1/org/dotnet/keycloak/439fa36d-6bc5-4ef5-95aa-ef29e64d2e9f", - "https://hrms.bangkok.go.th/api/v1/leave/check-time", - { headers: headers }, - ); + let response = http.get("https://{URL}", { headers: headers }); // ตรวจสอบการตอบสนอง check(response, {