diff --git a/pom.xml b/pom.xml index dd4e04a0..5749c203 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.iemr.mmu mmu-api - 3.9.0 + 3.12.0 war MMU-API @@ -294,7 +294,12 @@ h2 runtime - + + + software.amazon.awssdk + s3 + 2.25.60 + org.apache.commons commons-csv diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index faf5180f..cd33f081 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -5,6 +5,12 @@ spring.datasource.username=@env.DATABASE_USERNAME@ spring.datasource.password=@env.DATABASE_PASSWORD@ spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +## S3 storage for diagnostic documents pushed from a van +aws.s3.access-key=@env.AWS_S3_ACCESS_KEY@ +aws.s3.secret-key=@env.AWS_S3_SECRET_KEY@ +aws.s3.region=@env.AWS_S3_REGION@ +diagnostic.documents.s3.bucket=@env.DIAGNOSTIC_DOCUMENTS_S3_BUCKET@ + ## Carestream URLs, local carestreamOrderCreateURL=@env.COMMON_API@carestream/createOrder @@ -28,6 +34,11 @@ dataSyncUploadUrl=@env.MMU_CENTRAL_SERVER@dataSync/van-to-server ## Data download API, central dataSyncDownloadUrl=@env.MMU_CENTRAL_SERVER@dataSync/server-to-van +## Diagnostic document push (this server -> further central server) +diagnosticDocumentUploadUrl=@env.MMU_CENTRAL_SERVER@dataSync/diagnostic-documents +diagnosticDocument.push.batchSize=@env.DIAGNOSTIC_DOCUMENT_PUSH_BATCH_SIZE@ +diagnostic.documents.storage-root=@env.DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT@ + ## TC specialist slot booking, # not required tcSpecialistSlotBook=@env.TM_API@schedule/bookSlot @@ -95,4 +106,10 @@ logging.file.name=@env.MMU_API_LOGGING_FILE_NAME@ springdoc.api-docs.enabled=@env.SWAGGER_DOC_ENABLED@ springdoc.swagger-ui.enabled=@env.SWAGGER_DOC_ENABLED@ -cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@ \ No newline at end of file +cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@ + +## BenID auto top-up URLs (feature stays off: stoptb.* not set for these builds) +benCheckUrlLocal=@env.IDENTITY_API_BASE_URL@id/checkAvailablBenIDLocalServer +centralLoginUrl=@env.COMMON_API_OPEN_SYNC@user/userAuthenticate +centralRefreshTokenUrl=@env.COMMON_API_OPEN_SYNC@user/refreshToken +centralSessionUrl=@env.COMMON_API_OPEN_SYNC@user/getLoginResponse diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 28f6b89e..05a361a2 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -5,6 +5,12 @@ spring.datasource.username=${DATABASE_USERNAME} spring.datasource.password=${DATABASE_PASSWORD} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +## S3 storage for diagnostic documents pushed from a van +aws.s3.access-key=${AWS_S3_ACCESS_KEY:} +aws.s3.secret-key=${AWS_S3_SECRET_KEY:} +aws.s3.region=${AWS_S3_REGION:ap-south-1} +diagnostic.documents.s3.bucket=${DIAGNOSTIC_DOCUMENTS_S3_BUCKET:} + ## Carestream URLs, local carestreamOrderCreateURL=${COMMON_API}carestream/createOrder @@ -28,6 +34,11 @@ dataSyncUploadUrl=${MMU_CENTRAL_SERVER}dataSync/van-to-server ## Data download API, central dataSyncDownloadUrl=${MMU_CENTRAL_SERVER}dataSync/server-to-van +## Diagnostic document push (this server -> further central server) +diagnosticDocumentUploadUrl=${MMU_CENTRAL_SERVER}dataSync/diagnostic-documents +diagnosticDocument.push.batchSize=${DIAGNOSTIC_DOCUMENT_PUSH_BATCH_SIZE:3} +diagnostic.documents.storage-root=${DIAGNOSTIC_DOCUMENTS_STORAGE_ROOT} + ## TC specialist slot booking, # not required tcSpecialistSlotBook=${TM_API}schedule/bookSlot @@ -93,4 +104,18 @@ logging.path=logs logging.file.name=${MMU_API_LOGGING_FILE_NAME} springdoc.api-docs.enabled=${SWAGGER_DOC_ENABLED} -springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED} \ No newline at end of file +springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED} + +## BenID auto top-up (StopTB laptops only; off unless STOPTB_ENFORCE_VANID=true and STOPTB_VAN_ID>0) +benCheckUrlLocal=${IDENTITY_API_BASE_URL}id/checkAvailablBenIDLocalServer +centralLoginUrl=${COMMON_API_OPEN_SYNC}user/userAuthenticate +centralRefreshTokenUrl=${COMMON_API_OPEN_SYNC}user/refreshToken +centralSessionUrl=${COMMON_API_OPEN_SYNC}user/getLoginResponse +stoptb.enforce.vanid=${STOPTB_ENFORCE_VANID:false} +stoptb.van.id=${STOPTB_VAN_ID:0} +benid.pool.lowerLimit=${BENID_POOL_LOWER_LIMIT:2000} +benid.pool.batchSize=${BENID_POOL_BATCH_SIZE:1000} +benid.pool.bootstrapSize=${BENID_POOL_BOOTSTRAP_SIZE:10000} +benid.token.dir=${BENID_TOKEN_DIR:/opt/amrit/data} +benid.autoTopUp.initialDelayMs=${BENID_AUTO_TOPUP_INITIAL_DELAY_MS:120000} +benid.autoTopUp.checkIntervalMs=${BENID_AUTO_TOPUP_CHECK_INTERVAL_MS:300000} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 294c0e45..46996a54 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -7,6 +7,12 @@ spring.datasource.username= spring.datasource.password= spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +## S3 storage for diagnostic documents pushed from a van (POST /dataSync/diagnostic-documents) +aws.s3.access-key= +aws.s3.secret-key= +aws.s3.region=ap-south-1 +diagnostic.documents.s3.bucket= + ## Carestream URLs, local #carestreamOrderCreateURL =http://localhost:1040/carestream/createOrder carestreamOrderCreateURL =http://localhost:8083/carestream/createOrder @@ -44,6 +50,10 @@ dataSyncUploadUrl=http://10.208.122.38:8080/mmu-api/dataSync/van-to-server #dataSyncDownloadUrl=http://localhost:82/dataSync/server-to-van dataSyncDownloadUrl=http://10.208.122.38:8080/mmu-api/dataSync/server-to-van +diagnosticDocumentUploadUrl=http://localhost:8087/mmu-api/dataSync/diagnostic-documents +diagnosticDocument.push.batchSize=3 +diagnostic.documents.storage-root= + ## TC specialist slot booking, # not required #tcSpecialistSlotBook=http://localhost:8080/schedule/bookSlot tcSpecialistSlotBook=http://10.208.122.38:8080/tm-api/schedule/bookSlot @@ -110,4 +120,32 @@ logging.path=logs/ logging.file.name=logs/mmu-api.log -cors.allowed-origins=http://localhost:* \ No newline at end of file +cors.allowed-origins=http://localhost:* + +## BenID auto top-up (StopTB laptops only) +benCheckUrlLocal=http://localhost:8094/id/checkAvailablBenIDLocalServer +centralLoginUrl=https://uatstoptb.piramalswasthya.org/common-api/user/userAuthenticate +centralRefreshTokenUrl=https://uatstoptb.piramalswasthya.org/common-api/user/refreshToken +centralSessionUrl=https://uatstoptb.piramalswasthya.org/common-api/user/getLoginResponse +stoptb.enforce.vanid=false +stoptb.van.id=0 +benid.pool.lowerLimit=2000 +benid.pool.batchSize=1000 +benid.pool.bootstrapSize=10000 +benid.token.dir=/opt/amrit/data +benid.autoTopUp.initialDelayMs=120000 +benid.autoTopUp.checkIntervalMs=300000 + +## Automatic Data Sync (StopTB laptops only; needs the BenID central connection above). Off by default: manual sync only. +sync.autoUpload.enabled=false +sync.autoUpload.intervalMs=1800000 +sync.autoMasterDownload.enabled=false +sync.autoMasterDownload.intervalMs=21600000 +# automatic master download fetches only inventory masters (items, stores, item->store mapping); false = all masters +sync.autoMasterDownload.inventoryOnly=true +sync.autoInventoryDownload.enabled=false + +## Central upload: replace van IDs in link columns (BenVisitID, PrescriptionID) by central IDs. +## Off by default (other MMU servers unchanged); on for the StopTB central. +## Env alternative: DATASYNC_TRANSLATEVANLINKS=true +dataSync.translateVanLinks=false diff --git a/src/main/java/com/iemr/mmu/Application.java b/src/main/java/com/iemr/mmu/Application.java index 712d34f5..8fb40ccf 100644 --- a/src/main/java/com/iemr/mmu/Application.java +++ b/src/main/java/com/iemr/mmu/Application.java @@ -29,11 +29,13 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.scheduling.annotation.EnableScheduling; import com.iemr.mmu.data.login.Users; import com.iemr.mmu.utils.IEMRApplBeans; @SpringBootApplication +@EnableScheduling public class Application { public static void main(String[] args) { diff --git a/src/main/java/com/iemr/mmu/config/S3ClientConfig.java b/src/main/java/com/iemr/mmu/config/S3ClientConfig.java new file mode 100644 index 00000000..eb3509bf --- /dev/null +++ b/src/main/java/com/iemr/mmu/config/S3ClientConfig.java @@ -0,0 +1,53 @@ +package com.iemr.mmu.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +/*** + * @purpose S3 client beans used to store diagnostic documents pushed from a van. Built + * once and shared, rather than re-built per request. + */ +@Configuration +public class S3ClientConfig { + + @Value("${aws.s3.access-key}") + private String accessKey; + + @Value("${aws.s3.secret-key}") + private String secretKey; + + @Value("${aws.s3.region}") + private String region; + + /* + * Only the central server talks to S3; a van (camp laptop) runs the same war but never + * calls it, so it must be able to start without AWS keys. AwsBasicCredentials rejects blank + * keys at construction time, so fall back to the SDK default chain, which is resolved lazily + * on the first S3 call rather than at startup. + */ + private AwsCredentialsProvider credentialsProvider() { + if (accessKey == null || accessKey.isBlank() || secretKey == null || secretKey.isBlank()) { + return DefaultCredentialsProvider.create(); + } + return StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)); + } + + @Bean + public S3Client diagnosticDocumentS3Client() { + return S3Client.builder().region(Region.of(region)).credentialsProvider(credentialsProvider()).build(); + } + + @Bean + public S3Presigner diagnosticDocumentS3Presigner() { + return S3Presigner.builder().region(Region.of(region)).credentialsProvider(credentialsProvider()).build(); + } +} diff --git a/src/main/java/com/iemr/mmu/controller/benid/CentralAuthController.java b/src/main/java/com/iemr/mmu/controller/benid/CentralAuthController.java new file mode 100644 index 00000000..71533a5d --- /dev/null +++ b/src/main/java/com/iemr/mmu/controller/benid/CentralAuthController.java @@ -0,0 +1,94 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.controller.benid; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.mmu.service.benid.BenIdAutoTopUpService; +import com.iemr.mmu.service.benid.CentralAuthService; +import com.iemr.mmu.utils.response.OutputResponse; + +import io.swagger.v3.oas.annotations.Operation; + +@RestController +@RequestMapping(value = "/centralAuth", headers = "Authorization", produces = "application/json") +public class CentralAuthController { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName()); + + @Autowired + private CentralAuthService centralAuthService; + + @Autowired + private BenIdAutoTopUpService benIdAutoTopUpService; + + @Operation(summary = "Automatic BenID top-up and central connection status") + @GetMapping(value = "/status") + public String status() { + OutputResponse response = new OutputResponse(); + try { + response.setResponse(benIdAutoTopUpService.status().toString()); + } catch (Exception e) { + logger.error("Error reading BenID auto top-up status: " + e.getMessage()); + response.setError(e); + } + return response.toString(); + } + + /** + * Called by MMU-UI right after a successful login. Returns immediately; the + * central login and any top-up happen in the background. + */ + @Operation(summary = "Connect this laptop to central with the user's login (one time)") + @PostMapping(value = "/capture", consumes = "application/json") + public String capture(@RequestBody String requestOBJ) { + OutputResponse response = new OutputResponse(); + try { + if (!centralAuthService.isEnabled()) { + response.setResponse("disabled"); + return response.toString(); + } + JSONObject obj = new JSONObject(requestOBJ); + String userName = obj.optString("userName", null); + String password = obj.optString("password", null); + boolean force = obj.optBoolean("reconnect", false); + benIdAutoTopUpService.runInBackground(() -> { + if (centralAuthService.capture(userName, password, force)) { + benIdAutoTopUpService.triggerNow(); + } + }); + response.setResponse("accepted"); + } catch (Exception e) { + logger.error("Error in central capture: " + e.getMessage()); + response.setError(e); + } + return response.toString(); + } +} diff --git a/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java b/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java index a7f2a7c7..973fdf41 100644 --- a/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java +++ b/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java @@ -21,6 +21,8 @@ */ package com.iemr.mmu.controller.dataSyncActivity; +import java.util.Map; + import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,13 +33,18 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.google.gson.Gson; +import com.iemr.mmu.service.benid.BenIdAutoTopUpService; import com.iemr.mmu.service.dataSyncActivity.DownSyncDataFromServerImpl; import com.iemr.mmu.service.dataSyncActivity.DownloadDataFromServerImpl; import com.iemr.mmu.service.dataSyncActivity.DownloadDataFromServerTransactionalImpl; +import com.iemr.mmu.service.dataSyncActivity.AutoSyncService; import com.iemr.mmu.service.dataSyncActivity.UploadDataToServerImpl; +import com.iemr.mmu.service.dataSyncLayerCentral.DiagnosticDocumentFetchService; +import com.iemr.mmu.service.dataSyncLayerCentral.DiagnosticDocumentPushServiceImpl; import com.iemr.mmu.utils.CookieUtil; import com.iemr.mmu.utils.response.OutputResponse; @@ -60,7 +67,18 @@ public class StartSyncActivity { @Autowired private DownloadDataFromServerTransactionalImpl downloadDataFromServerTransactionalImpl; @Autowired + private DiagnosticDocumentPushServiceImpl diagnosticDocumentPushServiceImpl; + @Autowired + private DiagnosticDocumentFetchService diagnosticDocumentFetchService; + @Autowired private DownSyncDataFromServerImpl downSyncDataFromServerImpl; + + @Autowired + private BenIdAutoTopUpService benIdAutoTopUpService; + + @Autowired + private AutoSyncService autoSyncService; + private static final String GROUP_ID = "groupID"; private static final String PROVIDER_SERVICE_MAP_ID = "providerServiceMapID"; @@ -79,6 +97,8 @@ public String dataSyncToServer(@RequestBody String requestOBJ, serverAuthorization, jwtToken); // if (s != null) response.setResponse(s); + // internet is up right now: top up the BenID pool if it is low + benIdAutoTopUpService.triggerNow(); // else // response.setError(5000, "Error in data sync"); } else { @@ -91,6 +111,45 @@ public String dataSyncToServer(@RequestBody String requestOBJ, return response.toStringWithSerialization(); } + @Operation(summary = "Push pending diagnostic documents (docsProcessed='N') to the further central server, in batches") + @PostMapping(value = { "/diagnostic-documents-to-server" }) + public String diagnosticDocumentsToServer(@RequestHeader(value = "Authorization") String authorization, + @RequestHeader(value = "ServerAuthorization") String serverAuthorization, + @RequestParam(required = false) Long villageId) { + OutputResponse response = new OutputResponse(); + try { + String s = diagnosticDocumentPushServiceImpl.pushPendingDocuments(serverAuthorization); + if (s != null) + response.setResponse(s); + else + response.setError(5000, "Error in diagnostic document push"); + } catch (Exception e) { + logger.error("Error in diagnostic document push : " + e); + response.setError(e); + } + return response.toString(); + } + + @Operation(summary = "Fetch a short-lived download URL for the latest successfully-pushed diagnostic document matching a beneficiary+documentType, from this van's own local record") + @GetMapping(value = { "/diagnostic-documents/download" }) + public String diagnosticDocumentDownloadUrl(@RequestParam Long beneficiaryId, @RequestParam String documentType, + @RequestHeader(value = "Authorization") String authorization) { + OutputResponse response = new OutputResponse(); + try { + Map download = diagnosticDocumentFetchService.getLatestDocumentDownload(beneficiaryId, + documentType); + if (download != null) + response.setResponse(new Gson().toJson(download)); + else + response.setError(5000, "No pushed document found for beneficiaryId=" + beneficiaryId + + ", documentType=" + documentType); + } catch (Exception e) { + logger.error("Error fetching diagnostic document download URL : " + e); + response.setError(e); + } + return response.toString(); + } + @Operation(summary = "Get data sync group details") @GetMapping(value = { "/getSyncGroupDetails" }) public String getSyncGroupDetails() { @@ -131,8 +190,11 @@ public String startMasterDownload(@RequestBody String requestOBJ, if (s.equalsIgnoreCase("inProgress")) response.setError(5000, "Download is already in progress from different device, kindly wait to finish this"); - else + else { response.setResponse(s); + // internet is up right now: top up the BenID pool if it is low + benIdAutoTopUpService.triggerNow(); + } } else response.setError(5000, s); } else { @@ -147,6 +209,19 @@ public String startMasterDownload(@RequestBody String requestOBJ, return response.toString(); } + @Operation(summary = "Automatic data sync status and the rows central rejected on upload") + @GetMapping(value = { "/autoSyncStatus" }) + public String autoSyncStatus() { + OutputResponse response = new OutputResponse(); + try { + response.setResponse(autoSyncService.status().toString()); + } catch (Exception e) { + logger.error("Error while getting automatic sync status : " + e); + response.setError(e); + } + return response.toString(); + } + @Operation(summary = "Data sync master download progress check") @GetMapping(value = { "/checkMastersDownloadProgress" }) public String checkMastersDownloadProgress() { diff --git a/src/main/java/com/iemr/mmu/controller/dataSyncLayerCentral/MMUDataSyncVanToServer.java b/src/main/java/com/iemr/mmu/controller/dataSyncLayerCentral/MMUDataSyncVanToServer.java index 589e1dcb..1d2c8790 100644 --- a/src/main/java/com/iemr/mmu/controller/dataSyncLayerCentral/MMUDataSyncVanToServer.java +++ b/src/main/java/com/iemr/mmu/controller/dataSyncLayerCentral/MMUDataSyncVanToServer.java @@ -24,7 +24,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -35,11 +34,11 @@ import com.iemr.mmu.data.syncActivity_syncLayer.DownSyncDataDigester; import com.iemr.mmu.data.syncActivity_syncLayer.SyncDownloadMaster; import com.iemr.mmu.data.syncActivity_syncLayer.SyncUploadDataDigester; +import com.iemr.mmu.service.dataSyncLayerCentral.DiagnosticDocumentIngestService; import com.iemr.mmu.service.dataSyncLayerCentral.FetchDownloadDataImpl; import com.iemr.mmu.service.dataSyncLayerCentral.GetDataFromVanAndSyncToDBImpl; import com.iemr.mmu.service.dataSyncLayerCentral.GetDownSyncDataFromCentralImpl; import com.iemr.mmu.service.dataSyncLayerCentral.GetMasterDataFromCentralForVanImpl; -import com.iemr.mmu.utils.CookieUtil; import com.iemr.mmu.utils.response.OutputResponse; import io.swagger.v3.oas.annotations.Operation; @@ -61,6 +60,8 @@ public class MMUDataSyncVanToServer { private FetchDownloadDataImpl fetchDownloadDataImpl; @Autowired private GetDownSyncDataFromCentralImpl getDownSyncDataFromCentralImpl; + @Autowired + private DiagnosticDocumentIngestService diagnosticDocumentIngestService; @Operation(summary = "Sync data from van-to-server") @PostMapping(value = { "/van-to-server" }, consumes = "application/json", produces = "application/json") @@ -81,6 +82,24 @@ public String dataSyncToServer(@RequestBody String requestOBJ, return response.toString(); } + @Operation(summary = "Receive diagnostic documents pushed from a van and store each in S3 (no database write here)") + @PostMapping(value = { "/diagnostic-documents" }, consumes = "application/json", produces = "application/json") + public String diagnosticDocumentsFromVan(@RequestBody String requestOBJ, + @RequestHeader(value = "Authorization") String Authorization) { + OutputResponse response = new OutputResponse(); + try { + String s = diagnosticDocumentIngestService.ingestDocuments(requestOBJ); + if (s != null) + response.setResponse(s); + else + response.setError(5000, "diagnostic document ingest failed"); + } catch (Exception e) { + response.setError(e); + logger.error("Diagnostic document ingest Exception" + e); + } + return response.toString(); + } + @Operation(summary = "Download data from server-to-van") @PostMapping(value = { "/server-to-van" }, consumes = "application/json", produces = "application/json") public String dataDownloadFromServer(@RequestBody SyncDownloadMaster syncDownloadMaster, diff --git a/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java b/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java index 5302188d..fa3bd7f5 100644 --- a/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java +++ b/src/main/java/com/iemr/mmu/repo/stoptb/NikshayExportRepository.java @@ -146,8 +146,10 @@ public record NikshayRawRow(Long benRegId, String firstName, String middleLastNa + " d.incomeStatus AS socioeconomicStatus, " + " (SELECT o.chief_complaint FROM tb_stoptb_general_opd o WHERE o.ben_reg_id = m.BenRegId " + " AND o.deleted = 0 ORDER BY o.id DESC LIMIT 1) AS chiefComplaint, " - + " (SELECT ge.hiv_status FROM tb_stoptb_general_examination ge WHERE ge.beneficiary_reg_id = m.BenRegId " - + " AND ge.deleted = 0 ORDER BY ge.id DESC LIMIT 1) AS hivStatus, " + // hiv_status used to live on tb_stoptb_general_examination too, but that copy was + // dropped (AMRIT-DB V109) once tb_screening became the single source of truth. + + " (SELECT s.hiv_status FROM tb_screening s WHERE s.ben_reg_id = m.BenRegId " + + " AND (s.deleted = 0 OR s.deleted IS NULL) ORDER BY s.id DESC LIMIT 1) AS hivStatus, " + " d.IsHIVPositive AS isHivPos, " + " d.nikshayId AS existingNikshayId " + "FROM db_identity.i_beneficiarymapping m " diff --git a/src/main/java/com/iemr/mmu/repo/syncActivity_syncLayer/SyncUtilityClassRepo.java b/src/main/java/com/iemr/mmu/repo/syncActivity_syncLayer/SyncUtilityClassRepo.java index c146a7a9..dd054c41 100644 --- a/src/main/java/com/iemr/mmu/repo/syncActivity_syncLayer/SyncUtilityClassRepo.java +++ b/src/main/java/com/iemr/mmu/repo/syncActivity_syncLayer/SyncUtilityClassRepo.java @@ -31,4 +31,6 @@ @Repository public interface SyncUtilityClassRepo extends CrudRepository { List findBySyncTableGroupIDAndDeletedOrderBySyncTableDetailID(Integer groupID, Boolean deleted); + + List findByDeletedOrderBySyncTableDetailID(Boolean deleted); } diff --git a/src/main/java/com/iemr/mmu/service/benid/BenIdAutoTopUpService.java b/src/main/java/com/iemr/mmu/service/benid/BenIdAutoTopUpService.java new file mode 100644 index 00000000..56b78f16 --- /dev/null +++ b/src/main/java/com/iemr/mmu/service/benid/BenIdAutoTopUpService.java @@ -0,0 +1,372 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.service.benid; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.locks.ReentrantLock; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.iemr.mmu.utils.RestTemplateUtil; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; + +/** + * Keeps the local BenID pool filled without anyone clicking "Generate BenID". + * + * Runs only on StopTB laptops (stoptb.enforce.vanid=true and stoptb.van.id>0). + * Pool empty -> bootstrap size; pool below lower limit -> batch size. IDs + * received from central are written to a pending file before the local import, + * so a failed import is retried with the same IDs instead of reserving new ones. + */ +@Service +public class BenIdAutoTopUpService { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName()); + + /** Shared with the manual "Generate BenID" button so both never run together. */ + public static final ReentrantLock GENERATION_LOCK = new ReentrantLock(); + + private static final String PENDING_FILE_NAME = "benid-pending-import.json"; + private static final long TOKEN_KEEP_ALIVE_MS = 24 * 60 * 60_000L; + + @Autowired + private CentralAuthService centralAuthService; + + @Value("${benGenUrlCentral:}") + private String benGenUrlCentral; + + @Value("${benImportUrlLocal:}") + private String benImportUrlLocal; + + @Value("${benCheckUrlLocal:}") + private String benCheckUrlLocal; + + @Value("${benid.pool.lowerLimit:2000}") + private long lowerLimit; + + @Value("${benid.pool.batchSize:1000}") + private long batchSize; + + @Value("${benid.pool.bootstrapSize:10000}") + private long bootstrapSize; + + // central is asked for at most this many IDs per request: a big request can outlast the + // central gateway timeout, and a reply lost after central created the IDs wastes them + @Value("${benid.pool.maxPerRequest:1000}") + private long maxPerRequest; + + @Value("${benid.token.dir:/opt/amrit/data}") + private String dataDir; + + // wait after the 1st, 2nd, 3rd and later failures (5, 15, 30, 60 minutes) + @Value("${benid.autoTopUp.retryDelaysMs:300000,900000,1800000,3600000}") + private long[] retryDelaysMs; + + @Value("${benid.http.connectTimeoutMs:15000}") + private int connectTimeoutMs; + + @Value("${benid.http.readTimeoutMs:60000}") + private int readTimeoutMs; + + private RestTemplate restTemplate; + + private volatile int failureCount; + private volatile long nextAttemptAt; + private volatile long lastPoolCount = -1; + private volatile long lastTopUpAt; + private volatile long lastTopUpCount; + private volatile String lastError; + private volatile int pendingImportFailures; + + // one background thread for this feature, so network waits never hold shared pool threads + private final ExecutorService background = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "benid-auto-topup"); + t.setDaemon(true); + return t; + }); + + private static final int MAX_PENDING_IMPORT_FAILURES = 3; + + @PostConstruct + public void init() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(connectTimeoutMs); + factory.setReadTimeout(readTimeoutMs); + restTemplate = new RestTemplate(factory); + } + + @PreDestroy + public void shutdown() { + background.shutdownNow(); + } + + /** Runs a task on this feature's own background thread. */ + public void runInBackground(Runnable task) { + background.submit(task); + } + + /** Regular check; does nothing on central or non-StopTB deployments. */ + @Scheduled(initialDelayString = "${benid.autoTopUp.initialDelayMs:120000}", fixedDelayString = "${benid.autoTopUp.checkIntervalMs:300000}") + public void scheduledCheck() { + runOnce(false); + } + + /** Run straight away (after a login or a Data Sync), in the background. */ + public void triggerNow() { + if (centralAuthService.isEnabled()) { + background.submit(() -> runOnce(true)); + } + } + + private void runOnce(boolean ignoreBackoff) { + if (!centralAuthService.isEnabled()) { + return; + } + if (!ignoreBackoff && System.currentTimeMillis() < nextAttemptAt) { + return; + } + if (!GENERATION_LOCK.tryLock()) { + logger.info("BenID generation already running - skipping this check"); + return; + } + try { + // IDs already received but not saved locally are imported first + if (importPendingIfAny()) { + onSuccess(); + } + + long available = countLocalPool(); + lastPoolCount = available; + long needed = available <= 0 ? bootstrapSize : (available < lowerLimit ? batchSize : 0); + if (needed == 0) { + centralAuthService.keepAliveIfDue(TOKEN_KEEP_ALIVE_MS); + onSuccess(); + return; + } + + String key = centralAuthService.getCentralSessionKey(); + if (key == null) { + lastError = "No central connection - waiting for a user login"; + logger.warn("BenID pool low ({} for vanID {}) but no central token - waiting for a user login", + available, centralAuthService.getVanID()); + return; + } + + int imported = 0; + for (long remaining = needed; remaining > 0;) { + long chunk = Math.min(remaining, Math.max(1, maxPerRequest)); + imported += generateAndImport(centralAuthService.getVanID(), chunk, key, key, "datasync"); + remaining -= chunk; + } + lastPoolCount = countLocalPool(); + lastTopUpAt = System.currentTimeMillis(); + lastTopUpCount = imported; + logger.info("BenID auto top-up: vanID {} +{} IDs, pool now {}", centralAuthService.getVanID(), imported, + lastPoolCount); + onSuccess(); + } catch (Exception e) { + onFailure(e); + } finally { + GENERATION_LOCK.unlock(); + } + } + + /** + * Asks central for IDs for the van and imports them locally. The central + * response is written to the pending file first, so a failed local import + * does not lose IDs that central has already reserved for this van. + * + * @return number of IDs imported + */ + public int generateAndImport(int vanID, long count, String centralAuthorization, String localAuthorization, + String localToken) throws Exception { + JSONObject request = new JSONObject(); + request.put("vanID", vanID); + request.put("benIDRequired", count); + + HttpEntity centralRequest = RestTemplateUtil.createRequestEntity(request.toString(), + centralAuthorization, "datasync"); + logger.info("Requesting {} BenIDs from central for vanID {}", count, vanID); + long start = System.currentTimeMillis(); + ResponseEntity response = restTemplate.exchange(benGenUrlCentral, HttpMethod.POST, centralRequest, + String.class); + logger.info("Central BenID generation answered in {} ms", System.currentTimeMillis() - start); + JSONObject obj = new JSONObject(response.getBody()); + if (obj.optInt("statusCode") != 200 || !obj.has("data")) { + throw new IllegalStateException("Central BenID generation failed: " + obj.optString("errorMessage")); + } + JSONArray ids = obj.getJSONArray("data"); + for (int k = 0; k < ids.length(); k++) { + ids.getJSONObject(k).put("vanID", vanID); + } + writePending(ids.toString()); + long importStart = System.currentTimeMillis(); + importLocally(ids.toString(), localAuthorization, localToken); + deletePending(); + logger.info("Imported {} BenIDs locally in {} ms", ids.length(), System.currentTimeMillis() - importStart); + return ids.length(); + } + + private boolean importPendingIfAny() throws Exception { + Path pending = pendingPath(); + if (!Files.exists(pending)) { + return false; + } + logger.info("Importing BenIDs left from an earlier run"); + try { + String payload = new String(Files.readAllBytes(pending), StandardCharsets.UTF_8); + importLocally(payload, "datasync", "datasync"); + } catch (Exception e) { + pendingImportFailures++; + if (pendingImportFailures < MAX_PENDING_IMPORT_FAILURES) { + throw e; + } + // a file that keeps failing (e.g. half-written at a power cut) must not stop top-ups + Path setAside = Paths.get(dataDir, "benid-pending-import.failed-" + System.currentTimeMillis() + ".json"); + Files.move(pending, setAside, StandardCopyOption.REPLACE_EXISTING); + pendingImportFailures = 0; + logger.warn("Pending BenID file could not be imported {} times; moved to {} and continuing: {}", + MAX_PENDING_IMPORT_FAILURES, setAside, e.getMessage()); + return false; + } + pendingImportFailures = 0; + deletePending(); + return true; + } + + private void importLocally(String payload, String authorization, String token) { + HttpEntity localRequest = RestTemplateUtil.createRequestEntity(payload, authorization, token); + ResponseEntity response = restTemplate.exchange(benImportUrlLocal, HttpMethod.POST, localRequest, + String.class); + JSONObject obj = new JSONObject(response.getBody()); + if (obj.optInt("statusCode") != 200) { + throw new IllegalStateException("Local BenID import failed: " + obj.optString("errorMessage")); + } + } + + public long countLocalPool() { + HttpEntity request = RestTemplateUtil.createRequestEntity(null, "datasync", "datasync"); + ResponseEntity response = restTemplate.exchange(benCheckUrlLocal, HttpMethod.GET, request, + String.class); + JSONObject obj = new JSONObject(response.getBody()); + if (obj.optInt("statusCode") != 200) { + throw new IllegalStateException("Local BenID count failed: " + obj.optString("errorMessage")); + } + Object data = obj.get("data"); + JSONObject dataObj = data instanceof JSONObject ? (JSONObject) data : new JSONObject(data.toString()); + return Long.parseLong(dataObj.get("response").toString()); + } + + private void onSuccess() { + failureCount = 0; + nextAttemptAt = 0; + lastError = null; + } + + private void onFailure(Exception e) { + long base = retryDelaysMs[Math.min(failureCount, retryDelaysMs.length - 1)]; + // +/-20% so that many laptops do not all retry at the same moment after an outage + long delay = base <= 0 ? 0 : (long) (base * ThreadLocalRandom.current().nextDouble(0.8, 1.2)); + failureCount++; + nextAttemptAt = System.currentTimeMillis() + delay; + lastError = e.getMessage(); + logger.warn("BenID auto top-up failed (attempt {}), next try in {} s: {}", failureCount, delay / 1000, + e.getMessage()); + } + + private Path pendingPath() { + return Paths.get(dataDir, PENDING_FILE_NAME); + } + + private void writePending(String payload) { + try { + Files.createDirectories(Paths.get(dataDir)); + // write a temp file and swap it in, so a power cut never leaves a half-written file + Path tmp = Paths.get(dataDir, PENDING_FILE_NAME + ".tmp"); + Files.write(tmp, payload.getBytes(StandardCharsets.UTF_8)); + try { + Files.move(tmp, pendingPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(tmp, pendingPath(), StandardCopyOption.REPLACE_EXISTING); + } + } catch (Exception e) { + logger.warn("Could not write pending BenID file: {}", e.getMessage()); + } + } + + private void deletePending() { + try { + Files.deleteIfExists(pendingPath()); + } catch (Exception e) { + logger.warn("Could not delete pending BenID file: {}", e.getMessage()); + } + } + + // ---------------------------------------------------------------- status + + public JSONObject status() { + JSONObject s = new JSONObject(); + s.put("enabled", centralAuthService.isEnabled()); + if (!centralAuthService.isEnabled()) { + return s; + } + s.put("vanID", centralAuthService.getVanID()); + s.put("centralConnection", centralAuthService.getState().name()); + s.put("connectedUser", centralAuthService.getConnectedUser()); + try { + lastPoolCount = countLocalPool(); + } catch (Exception e) { + logger.debug("Live BenID count not available: {}", e.getMessage()); + } + s.put("poolCount", lastPoolCount); + s.put("lowerLimit", lowerLimit); + s.put("lastTopUpAt", lastTopUpAt); + s.put("lastTopUpCount", lastTopUpCount); + s.put("pendingImport", Files.exists(pendingPath())); + String error = lastError != null ? lastError : centralAuthService.getLastError(); + if (error != null) { + s.put("lastError", error); + } + return s; + } +} diff --git a/src/main/java/com/iemr/mmu/service/benid/CentralAuthService.java b/src/main/java/com/iemr/mmu/service/benid/CentralAuthService.java new file mode 100644 index 00000000..d065eb19 --- /dev/null +++ b/src/main/java/com/iemr/mmu/service/benid/CentralAuthService.java @@ -0,0 +1,416 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.service.benid; + +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import jakarta.annotation.PostConstruct; + +/** + * Keeps this laptop connected to the central server without storing any + * central password. + * + * The operator's normal laptop login is passed here once; it is used for a + * single central login as an API client, which returns a refresh token. Only + * that refresh token is kept (AES-GCM encrypted with a key tied to this + * machine). Each time a central session is needed the refresh token is + * exchanged for a new JWT + refresh token, and the JWT for a central session + * key. + */ +@Service +public class CentralAuthService { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName()); + + public enum State { + DISABLED, NOT_CONNECTED, CONNECTED, RECONNECT_NEEDED, PASSWORD_MISMATCH + } + + // Central refresh tokens are only issued to API (mobile) clients. + private static final String API_CLIENT_USER_AGENT = "okhttp/4.12.0"; + private static final String TOKEN_FILE_NAME = "central-refresh-token"; + // user name only (no password): a laptop password central rejected is not sent again, + // also after a restart, so the central account cannot get locked by repeated attempts + private static final String REJECTED_FILE_NAME = "central-login-rejected"; + + @Value("${stoptb.enforce.vanid:false}") + private boolean enforceVanID; + + @Value("${stoptb.van.id:0}") + private int vanID; + + @Value("${centralLoginUrl:}") + private String centralLoginUrl; + + @Value("${centralRefreshTokenUrl:}") + private String centralRefreshTokenUrl; + + @Value("${centralSessionUrl:}") + private String centralSessionUrl; + + @Value("${benid.token.dir:/opt/amrit/data}") + private String tokenDir; + + @Value("${benid.token.secret:amrit-mmu-benid}") + private String tokenSecret; + + @Value("${benid.http.connectTimeoutMs:15000}") + private int connectTimeoutMs; + + @Value("${benid.http.readTimeoutMs:60000}") + private int readTimeoutMs; + + private RestTemplate restTemplate; + + // in-memory copy; the file is only a durable backup of this value + private volatile String refreshToken; + private volatile State state = State.NOT_CONNECTED; + private volatile String lastError; + private volatile long lastRefreshAt; + private volatile String connectedUser; + private volatile String rejectedUser; + + @PostConstruct + public void init() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(connectTimeoutMs); + factory.setReadTimeout(readTimeoutMs); + restTemplate = new RestTemplate(factory); + + if (!isEnabled()) { + state = State.DISABLED; + logger.info("BenID auto top-up disabled (stoptb.enforce.vanid={}, stoptb.van.id={})", enforceVanID, + vanID); + return; + } + refreshToken = readTokenFile(); + rejectedUser = readRejectedUser(); + state = refreshToken != null ? State.CONNECTED : State.NOT_CONNECTED; + logger.info("BenID auto top-up enabled for vanID {}; central token {}", vanID, + refreshToken != null ? "loaded" : "not present yet"); + } + + public boolean isEnabled() { + return enforceVanID && vanID > 0 && !centralLoginUrl.isEmpty(); + } + + public int getVanID() { + return vanID; + } + + public boolean hasToken() { + return refreshToken != null; + } + + public State getState() { + return state; + } + + public String getLastError() { + return lastError; + } + + public long getLastRefreshAt() { + return lastRefreshAt; + } + + public String getConnectedUser() { + return connectedUser; + } + + /** + * One central login with the operator's credentials (password already + * encrypted the way MMU-UI sends it to Common-API). Skipped when a working + * token already exists, so central is not hit on every laptop login. A + * rejected password is never retried, so the central account cannot get + * locked by this. + * + * @param force true when the user explicitly reconnects (e.g. Data Sync + * login), even if a token exists + * @return true if a new token was saved + */ + public synchronized boolean capture(String userName, String encryptedPassword, boolean force) { + if (!isEnabled() || userName == null || encryptedPassword == null) { + return false; + } + if (!force && refreshToken != null && state == State.CONNECTED) { + return false; + } + // a laptop password central already rejected is not sent again (account lock risk); + // only a Data Sync login (force, real central password) tries again + if (!force && userName.trim().equalsIgnoreCase(rejectedUser)) { + logger.info("Central login skipped for user {}: password was rejected before", userName); + return false; + } + try { + JSONObject body = new JSONObject(); + body.put("userName", userName.trim()); + body.put("password", encryptedPassword); + body.put("doLogout", false); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HttpHeaders.USER_AGENT, API_CLIENT_USER_AGENT); + + ResponseEntity response = restTemplate.exchange(centralLoginUrl, HttpMethod.POST, + new HttpEntity<>(body.toString(), headers), String.class); + JSONObject obj = new JSONObject(response.getBody()); + if (obj.optInt("statusCode") == 200 && obj.has("data") + && obj.getJSONObject("data").has("refreshToken")) { + saveToken(obj.getJSONObject("data").getString("refreshToken")); + connectedUser = userName.trim(); + rejectedUser = null; + deleteRejectedUser(); + state = State.CONNECTED; + lastError = null; + logger.info("Central token captured for user {}", connectedUser); + return true; + } + // wrong username/password or account problem: do not retry with these credentials + lastError = obj.optString("errorMessage", "Central login rejected"); + rejectedUser = userName.trim(); + saveRejectedUser(rejectedUser); + state = State.PASSWORD_MISMATCH; + logger.warn("Central login rejected for user {}: {}", userName, lastError); + } catch (Exception e) { + // network problem: nothing saved, next login tries again + lastError = "Central server not reachable: " + e.getMessage(); + logger.warn("Central login not possible right now: {}", e.getMessage()); + } + return false; + } + + /** + * Returns a fresh central session key (refreshToken -> JWT -> session key), + * or null if this laptop has no usable token. Throws on network errors so + * callers can retry later. + */ + public synchronized String getCentralSessionKey() throws Exception { + if (!isEnabled() || refreshToken == null) { + return null; + } + String jwt; + long start = System.currentTimeMillis(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + JSONObject body = new JSONObject(); + body.put("refreshToken", refreshToken); + ResponseEntity response = restTemplate.exchange(centralRefreshTokenUrl, HttpMethod.POST, + new HttpEntity<>(body.toString(), headers), String.class); + JSONObject obj = new JSONObject(response.getBody()); + jwt = obj.getString("jwtToken"); + if (obj.has("refreshToken")) { + saveToken(obj.getString("refreshToken")); + } + lastRefreshAt = System.currentTimeMillis(); + logger.info("Central token refreshed in {} ms", lastRefreshAt - start); + } catch (HttpStatusCodeException e) { + if (e.getStatusCode().value() == 401) { + // expired, revoked or user deactivated on central + logger.warn("Central refresh token rejected ({}); a new login is needed", + e.getResponseBodyAsString()); + deleteToken(); + state = State.RECONNECT_NEEDED; + lastError = "Central connection expired, please log in again"; + return null; + } + throw e; + } + + // JWT -> central session key; Common-API creates a new Redis session for it + long sessionStart = System.currentTimeMillis(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Jwttoken", jwt); + ResponseEntity response = restTemplate.exchange(centralSessionUrl, HttpMethod.POST, + new HttpEntity<>("{}", headers), String.class); + JSONObject obj = new JSONObject(response.getBody()); + if (obj.optInt("statusCode") != 200 || !obj.has("data")) { + throw new IllegalStateException("Central session not created: " + obj.optString("errorMessage")); + } + Object data = obj.get("data"); + JSONObject dataObj = data instanceof JSONObject ? (JSONObject) data : new JSONObject(data.toString()); + state = State.CONNECTED; + lastError = null; + logger.info("Central session created in {} ms", System.currentTimeMillis() - sessionStart); + return dataObj.getString("key"); + } + + /** Keeps the 90-day token alive even when no IDs are needed. */ + public void keepAliveIfDue(long maxAgeMs) { + if (refreshToken != null && System.currentTimeMillis() - lastRefreshAt > maxAgeMs) { + try { + getCentralSessionKey(); + } catch (Exception e) { + logger.debug("Token keep-alive skipped: {}", e.getMessage()); + } + } + } + + // ---------------------------------------------------------------- token file + + private Path tokenPath() { + return Paths.get(tokenDir, TOKEN_FILE_NAME); + } + + private void saveToken(String token) { + refreshToken = token; + try { + Path dir = Paths.get(tokenDir); + Files.createDirectories(dir); + setPermissions(dir, "rwx------"); + Path tmp = dir.resolve(TOKEN_FILE_NAME + ".tmp"); + Files.write(tmp, encrypt(token).getBytes(StandardCharsets.UTF_8)); + setPermissions(tmp, "rw-------"); + try { + Files.move(tmp, tokenPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, tokenPath(), StandardCopyOption.REPLACE_EXISTING); + } + } catch (Exception e) { + // keep working from memory; the token is captured again after a restart + logger.warn("Could not save central token to {} (kept in memory only): {}", tokenDir, e.getMessage()); + } + } + + private String readTokenFile() { + try { + Path path = tokenPath(); + if (Files.exists(path)) { + return decrypt(new String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim()); + } + } catch (Exception e) { + logger.warn("Stored central token could not be read, a new login is needed: {}", e.getMessage()); + } + return null; + } + + private void deleteToken() { + refreshToken = null; + try { + Files.deleteIfExists(tokenPath()); + } catch (Exception e) { + logger.warn("Could not delete central token file: {}", e.getMessage()); + } + } + + private String readRejectedUser() { + try { + Path path = Paths.get(tokenDir, REJECTED_FILE_NAME); + if (Files.exists(path)) { + String user = new String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim(); + return user.isEmpty() ? null : user; + } + } catch (Exception e) { + logger.warn("Could not read rejected-login marker: {}", e.getMessage()); + } + return null; + } + + private void saveRejectedUser(String user) { + try { + Path dir = Paths.get(tokenDir); + Files.createDirectories(dir); + Path path = dir.resolve(REJECTED_FILE_NAME); + Files.write(path, user.getBytes(StandardCharsets.UTF_8)); + setPermissions(path, "rw-------"); + } catch (Exception e) { + logger.warn("Could not save rejected-login marker: {}", e.getMessage()); + } + } + + private void deleteRejectedUser() { + try { + Files.deleteIfExists(Paths.get(tokenDir, REJECTED_FILE_NAME)); + } catch (Exception e) { + logger.warn("Could not delete rejected-login marker: {}", e.getMessage()); + } + } + + private static void setPermissions(Path path, String perms) { + try { + Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(perms)); + } catch (Exception e) { + // non-POSIX file system; nothing more we can do + } + } + + private SecretKeySpec machineKey() throws Exception { + String machineId; + try { + machineId = new String(Files.readAllBytes(Paths.get("/etc/machine-id")), StandardCharsets.UTF_8).trim(); + } catch (Exception e) { + machineId = InetAddress.getLocalHost().getHostName(); + } + byte[] key = MessageDigest.getInstance("SHA-256") + .digest((machineId + ":" + tokenSecret).getBytes(StandardCharsets.UTF_8)); + return new SecretKeySpec(key, "AES"); + } + + private String encrypt(String plain) throws Exception { + byte[] iv = new byte[12]; + new SecureRandom().nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, machineKey(), new GCMParameterSpec(128, iv)); + byte[] enc = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + byte[] out = new byte[iv.length + enc.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(enc, 0, out, iv.length, enc.length); + return Base64.getEncoder().encodeToString(out); + } + + private String decrypt(String encoded) throws Exception { + byte[] in = Base64.getDecoder().decode(encoded); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, machineKey(), new GCMParameterSpec(128, in, 0, 12)); + return new String(cipher.doFinal(in, 12, in.length - 12), StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncService.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncService.java new file mode 100644 index 00000000..e8971fe7 --- /dev/null +++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncService.java @@ -0,0 +1,239 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.service.dataSyncActivity; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.locks.ReentrantLock; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import com.iemr.mmu.service.benid.BenIdAutoTopUpService; +import com.iemr.mmu.service.benid.CentralAuthService; + +import jakarta.annotation.PreDestroy; + +/** + * StopTB laptops: runs Data Sync by itself, so central stays current and laptops get master changes without + * anyone pressing Upload or Download Masters. + * + * - Upload (van to central) every sync.autoUpload.intervalMs (default 30 min). + * - Master download (central to van) every sync.autoMasterDownload.intervalMs (default 6 h). + * + * Uses the central connection of the automatic BenID download (CentralAuthService): no password is stored, and + * SyncedBy is the operator who connected the laptop. Active only where CentralAuthService is enabled + * (stoptb.enforce.vanid=true and stoptb.van.id>0); central and other deployments are unaffected. The manual + * buttons keep working as before. + * + * Off by default (manual sync only) until the automatic schedule is approved: set sync.autoUpload.enabled, + * sync.autoMasterDownload.enabled and sync.autoInventoryDownload.enabled to true to switch it on. + */ +@Service +public class AutoSyncService { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName()); + + /** Upload and master download never run at the same time from here. */ + private static final ReentrantLock SYNC_LOCK = new ReentrantLock(); + + @Autowired + private CentralAuthService centralAuthService; + + @Autowired + private UploadDataToServerImpl uploadDataToServerImpl; + + @Autowired + private DownloadDataFromServerImpl downloadDataFromServerImpl; + + @Autowired + private DownloadDataFromServerTransactionalImpl downloadDataFromServerTransactional; + + @Autowired + private DataSyncRepository dataSyncRepository; + + @Autowired + private BenIdAutoTopUpService benIdAutoTopUpService; + + @Value("${sync.autoUpload.enabled:false}") + private boolean uploadEnabled; + + @Value("${sync.autoMasterDownload.enabled:false}") + private boolean masterDownloadEnabled; + + // refills / stock issued at central for this van's store ("Inventory Data Download"), after each upload + @Value("${sync.autoInventoryDownload.enabled:false}") + private boolean inventoryDownloadEnabled; + + // automatic master download fetches only the inventory masters (items, stores, item->store mapping, Min Stock); + // other masters (users, complaints, villages) come with the manual Download Masters button + @Value("${sync.autoMasterDownload.inventoryOnly:true}") + private boolean masterDownloadInventoryOnly; + + static final Set INVENTORY_MASTERS = Set.of("m_item", "m_itemcategory", "m_itemissueconfig", "m_uom", + "m_manufacturer", "m_pharmacologicalcategory", "m_supplier", "m_facilitytype", "m_facility", + "m_itemfacilitymapping"); + + private volatile long lastUploadAt; + private volatile String lastUploadResult; + private volatile long lastMasterDownloadAt; + private volatile String lastMasterDownloadResult; + private volatile long lastInventoryDownloadAt; + private volatile String lastInventoryDownloadResult; + private volatile String lastError; + + // own thread: a long upload must not hold the shared scheduler thread + private final ExecutorService background = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "auto-data-sync"); + t.setDaemon(true); + return t; + }); + + @PreDestroy + public void shutdown() { + background.shutdownNow(); + } + + @Scheduled(initialDelayString = "${sync.autoUpload.initialDelayMs:600000}", fixedDelayString = "${sync.autoUpload.intervalMs:1800000}") + public void scheduledUpload() { + if (uploadEnabled && centralAuthService.isEnabled()) { + background.submit(this::uploadOnce); + } + } + + @Scheduled(initialDelayString = "${sync.autoMasterDownload.initialDelayMs:900000}", fixedDelayString = "${sync.autoMasterDownload.intervalMs:21600000}") + public void scheduledMasterDownload() { + if (masterDownloadEnabled && centralAuthService.isEnabled()) { + background.submit(this::masterDownloadOnce); + } + } + + void uploadOnce() { + if (!SYNC_LOCK.tryLock()) { + return; + } + try { + String key = centralAuthService.getCentralSessionKey(); + if (key == null) { + lastError = "No central connection - waiting for a user login"; + return; + } + int vanID = centralAuthService.getVanID(); + String user = centralAuthService.getConnectedUser(); + String result = uploadDataToServerImpl.getDataToSyncToServer(vanID, + user != null && !user.isBlank() ? user : "auto-sync", key, null); + lastUploadAt = System.currentTimeMillis(); + lastUploadResult = result; + lastError = null; + logger.info("Automatic upload for vanID {} finished: {}", vanID, result); + // internet is up: top up the BenID pool if it is low (same as the manual upload) + benIdAutoTopUpService.triggerNow(); + if (inventoryDownloadEnabled) + inventoryDownload(vanID, key); + } catch (Exception e) { + lastError = "Upload: " + e.getMessage(); + logger.warn("Automatic upload failed: {}", e.getMessage()); + } finally { + SYNC_LOCK.unlock(); + } + } + + // Same as the "Inventory Data Download" button: indents, indent issues, stock transfers and stock entries + // that central has for this van's store. Its own failure does not undo the upload. + private void inventoryDownload(int vanID, String key) { + try { + int done = downloadDataFromServerTransactional.downloadTransactionalData(vanID, key, "datasync"); + lastInventoryDownloadAt = System.currentTimeMillis(); + lastInventoryDownloadResult = done > 0 ? "success" : "nothing downloaded"; + logger.info("Automatic inventory download for vanID {} finished: {}", vanID, lastInventoryDownloadResult); + } catch (Exception e) { + lastError = "Inventory download: " + e.getMessage(); + logger.warn("Automatic inventory download failed: {}", e.getMessage()); + } + } + + void masterDownloadOnce() { + if (!SYNC_LOCK.tryLock()) { + return; + } + try { + String key = centralAuthService.getCentralSessionKey(); + if (key == null) { + lastError = "No central connection - waiting for a user login"; + return; + } + int vanID = centralAuthService.getVanID(); + Integer psm = dataSyncRepository.getVanProviderServiceMapID(vanID); + if (psm == null) { + lastError = "Master download: van " + vanID + " not found in m_van"; + return; + } + String result = downloadDataFromServerImpl.downloadMasterDataFromServer(key, null, vanID, psm, + masterDownloadInventoryOnly ? INVENTORY_MASTERS : null); + if ("inProgress".equalsIgnoreCase(result)) { + logger.info("Master download already running (manual) - skipping the automatic one"); + return; + } + lastMasterDownloadAt = System.currentTimeMillis(); + lastMasterDownloadResult = result; + lastError = null; + logger.info("Automatic master download for vanID {} finished: {}", vanID, result); + } catch (Exception e) { + lastError = "Master download: " + e.getMessage(); + logger.warn("Automatic master download failed: {}", e.getMessage()); + } finally { + SYNC_LOCK.unlock(); + } + } + + /** For the Data Sync screen: last runs and the rows central has rejected. */ + public JSONObject status() { + JSONObject s = new JSONObject(); + s.put("enabled", centralAuthService.isEnabled()); + s.put("uploadEnabled", uploadEnabled); + s.put("masterDownloadEnabled", masterDownloadEnabled); + s.put("lastUploadAt", lastUploadAt); + s.put("lastUploadResult", lastUploadResult == null ? JSONObject.NULL : lastUploadResult); + s.put("lastMasterDownloadAt", lastMasterDownloadAt); + s.put("lastMasterDownloadResult", + lastMasterDownloadResult == null ? JSONObject.NULL : lastMasterDownloadResult); + s.put("inventoryDownloadEnabled", inventoryDownloadEnabled); + s.put("lastInventoryDownloadAt", lastInventoryDownloadAt); + s.put("lastInventoryDownloadResult", + lastInventoryDownloadResult == null ? JSONObject.NULL : lastInventoryDownloadResult); + s.put("lastError", lastError == null ? JSONObject.NULL : lastError); + List> failures = dataSyncRepository.getSyncFailures(); + long failedRows = failures.stream().mapToLong(f -> ((Number) f.get("failedRows")).longValue()).sum(); + s.put("failedRows", failedRows); + s.put("failures", new JSONArray(failures)); + return s; + } +} diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DataSyncRepository.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DataSyncRepository.java index 75a74842..99b4ec04 100644 --- a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DataSyncRepository.java +++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DataSyncRepository.java @@ -23,9 +23,11 @@ import java.sql.Timestamp; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.sql.PreparedStatement; @@ -40,6 +42,7 @@ import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Service; +import com.iemr.mmu.data.syncActivity_syncLayer.SyncUtilityClass; import com.iemr.mmu.repo.syncActivity_syncLayer.SyncUtilityClassRepo; import com.iemr.mmu.utils.validator.SqlIdentifierValidator; @@ -127,9 +130,103 @@ public int updateProcessedFlagInVan(String schemaName, String tableName, List psm = getJdbcTemplate().queryForList( + "SELECT ProviderServiceMapID FROM db_iemr.m_van WHERE VanID = ? AND Deleted = false", Integer.class, + vanID); + return psm.isEmpty() ? null : psm.get(0); + } + + /** + * Rows central rejected on upload (Processed = 'F'), per sync table and reason. They are sent again on every + * upload until central accepts them. + */ + public List> getSyncFailures() { + jdbcTemplate = getJdbcTemplate(); + List> result = new ArrayList<>(); + Set seen = new HashSet<>(); + for (SyncUtilityClass table : syncutilityClassRepo.findByDeletedOrderBySyncTableDetailID(false)) { + if (Boolean.TRUE.equals(table.getIsMaster()) || table.getSchemaName() == null + || table.getTableName() == null) + continue; + String qualified = SqlIdentifierValidator.validatedSchemaName(table.getSchemaName()) + "." + + SqlIdentifierValidator.validatedTableName(table.getTableName()); + if (!seen.add(qualified.toLowerCase())) + continue; + try { + List> rows = jdbcTemplate.queryForList("SELECT '" + table.getTableName() + + "' AS tableName, SyncFailureReason AS reason, COUNT(*) AS failedRows, DATE_FORMAT(MAX(SyncedDate), '%d-%m-%Y %H:%i') AS lastTried " + + "FROM " + qualified + " WHERE Processed = 'F' GROUP BY SyncFailureReason"); + result.addAll(rows); + } catch (Exception e) { + logger.warn("Sync failure check skipped for {}: {}", qualified, e.getMessage()); + } + } + return result; + } + // ---------------------------------- End of Upload repository // ---------------------------------- Download Repository + + /** + * Down-sync copies the beneficiary tables but not i_ben_flow_outreach, and the tab download + * (FLW-API getBeneficiaryData) only serves beneficiaries that have a worklist row. So a + * beneficiary registered elsewhere and down-synced here never reached this laptop's tabs. + * Creates the missing worklist row for this van's beneficiaries, filled as TM-API does at + * registration. Processed = 'P' so it is not uploaded back: central already has its own row. + */ + public int createMissingWorklistRows(int vanID, int providerServiceMapID) { + jdbcTemplate = getJdbcTemplate(); + + String village = "(SELECT h.%s FROM db_identity.i_beneficiarydetails_rmnch r " + + " JOIN db_identity.i_householddetails h ON h.houseoldId = r.houseoldId " + + " WHERE r.BeneficiaryRegID = bm.BenRegId LIMIT 1)"; + + String insert = "INSERT INTO db_iemr.i_ben_flow_outreach " + + " (beneficiary_reg_id, beneficiary_id, visit_no, ben_name, ben_age, ben_dob, ben_gender, " + + " ben_gender_val, ben_age_val, ben_phone_no, father_name, spouse_name, nurse_flag, doctor_flag, " + + " pharmacist_flag, doctor_signature_flag, BenArrivedFlag, created_by, created_date, modified_by, " + + " modified_date, registrationDate, districtID, district, villageID, village, " + + " providerServiceMapID, vanID, Processed, deleted) " + + " SELECT * FROM ( SELECT bm.BenRegId, rg.BeneficiaryID, 1 AS visit_no, " + + " TRIM(CONCAT_WS(' ', d.FirstName, NULLIF(d.MiddleName, ''), d.LastName)) AS ben_name, " + + " CONCAT(TIMESTAMPDIFF(YEAR, d.DOB, bm.CreatedDate), ' years - ', " + + " MOD(TIMESTAMPDIFF(MONTH, d.DOB, bm.CreatedDate), 12), ' months') AS ben_age, " + + " d.DOB, d.Gender, d.GenderId, TIMESTAMPDIFF(YEAR, d.DOB, bm.CreatedDate) AS ben_age_val, " + + " COALESCE(c.PreferredPhoneNum, c.PhoneNum1) AS phone, " + + " COALESCE(d.FatherName, '') AS father, COALESCE(d.SpouseName, '') AS spouse, " + + " 1 AS nf, 0 AS df, 0 AS pf, 0 AS dsf, 0 AS baf, bm.CreatedBy, bm.CreatedDate AS cdate, " + + " 'downsync' AS mby, NOW() AS mdate, bm.CreatedDate AS rdate, " + + " COALESCE(a.CurrDistrictId, a.PermDistrictId) AS districtID, " + + " COALESCE(a.CurrDistrict, a.PermDistrict) AS district, " + + " COALESCE(" + String.format(village, "villageid") + ", a.CurrVillageId, a.PermVillageId) AS villageID, " + + " COALESCE(" + String.format(village, "village") + ", a.CurrVillage, a.PermVillage) AS village, " + + " ? AS psm, bm.VanID, 'P' AS processed, 0 AS deleted " + + " FROM db_identity.i_beneficiarymapping bm " + + " JOIN db_identity.m_beneficiaryregidmapping rg ON rg.BenRegId = bm.BenRegId " + + " JOIN db_identity.i_beneficiarydetails d ON d.BeneficiaryDetailsId = bm.BenDetailsId " + + " LEFT JOIN db_identity.i_beneficiarycontacts c ON c.BenContactsID = bm.BenContactsId " + + " LEFT JOIN db_identity.i_beneficiaryaddress a ON a.BenAddressID = bm.BenAddressId " + + " WHERE bm.VanID = ? AND bm.Deleted = false " + // the van can hold beneficiaries of other service lines; only its own get a row + + " AND (SELECT r.ProviderServiceMapID FROM db_identity.i_beneficiarydetails_rmnch r " + + " WHERE r.BeneficiaryRegID = bm.BenRegId LIMIT 1) = ? " + + " AND NOT EXISTS (SELECT 1 FROM db_iemr.i_ben_flow_outreach f " + + " WHERE f.beneficiary_reg_id = bm.BenRegId) ) x " + + " WHERE x.villageID IS NOT NULL"; + + int inserted = jdbcTemplate.update(insert, providerServiceMapID, vanID, providerServiceMapID); + + if (inserted > 0) + // same convention as TM-API: VanSerialNo is the row's own id + jdbcTemplate.update("UPDATE db_iemr.i_ben_flow_outreach SET VanSerialNo = ben_flow_id " + + " WHERE modified_by = 'downsync' AND VanSerialNo IS NULL"); + + return inserted; + } + public int[] updateLatestMasterInLocal(String query, List syncDataList) { int[] i = null; // get JDBC template @@ -176,6 +273,17 @@ public String resolveLastModColumn(String schema, String table) { return (found == null || found.isEmpty()) ? null : found.get(0); } + public Set getTableColumns(String schema, String table) { + jdbcTemplate = getJdbcTemplate(); + + String query = " SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? "; + + Set columns = new HashSet<>(); + for (String c : jdbcTemplate.queryForList(query, String.class, schema, table)) + columns.add(c.toLowerCase()); + return columns; + } + public List getDownSyncColumns(String schema, String table) { jdbcTemplate = getJdbcTemplate(); diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownSyncDataFromServerImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownSyncDataFromServerImpl.java index c3c1e1d0..2c70854e 100644 --- a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownSyncDataFromServerImpl.java +++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownSyncDataFromServerImpl.java @@ -163,6 +163,7 @@ public String startDownSync(String serverAuthorization, String jwtToken, Integer } progressCounter++; } + createMissingWorklistRows(vanID, providerServiceMapID); countOutstandingConflicts(downSyncTables, vanID); } finally { IN_PROGRESS.set(false); @@ -207,6 +208,23 @@ public Map getDownSyncStatus() { return resultMap; } + /** Down-synced beneficiaries need a worklist row before this laptop's tabs can download them. */ + private void createMissingWorklistRows(Integer vanID, Integer providerServiceMapID) { + try { + Integer psm = providerServiceMapID != null ? providerServiceMapID + : dataSyncRepository.getVanProviderServiceMapID(vanID); + if (psm == null) { + logger.warn("Worklist rows not created after down-sync: no service line for van {}", vanID); + return; + } + int created = dataSyncRepository.createMissingWorklistRows(vanID, psm); + logger.info("Created {} worklist rows for down-synced beneficiaries of van {}", created, vanID); + } catch (Exception e) { + // the down-sync itself already succeeded; a failure here must not undo it + logger.error("Creating worklist rows after down-sync failed for van " + vanID + " : " + e.getMessage(), e); + } + } + private void countOutstandingConflicts(List downSyncTables, Integer vanID) { outstandingConflictCounter = 0; outstandingConflictTables.clear(); diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java index 6a6d12bc..572724be 100644 --- a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java +++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java @@ -27,9 +27,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import org.json.JSONObject; import org.slf4j.Logger; @@ -45,6 +47,7 @@ import com.google.gson.Gson; import com.iemr.mmu.data.syncActivity_syncLayer.MasterDownloadDataDigester; +import com.iemr.mmu.service.benid.BenIdAutoTopUpService; import com.iemr.mmu.data.syncActivity_syncLayer.SyncDownloadMaster; import com.iemr.mmu.data.syncActivity_syncLayer.TempVan; import com.iemr.mmu.repo.syncActivity_syncLayer.SyncDownloadMasterRepo; @@ -60,6 +63,9 @@ public class DownloadDataFromServerImpl implements DownloadDataFromServer { // rest URLs from server to download master data for van @Value("${dataSyncDownloadUrl}") private String dataSyncDownloadUrl; + // StopTB camp laptop (laptops are set up from copied databases); other MMU deployments keep standard behaviour + @Value("${stoptb.enforce.vanid:false}") + private boolean stopTBLaptop; @Autowired private SyncDownloadMasterRepo syncDownloadMasterRepo; @Autowired @@ -95,6 +101,14 @@ public class DownloadDataFromServerImpl implements DownloadDataFromServer { */ public String downloadMasterDataFromServer(String ServerAuthorization, String jwtToken, Integer vanID, Integer psmID) throws Exception { + return downloadMasterDataFromServer(ServerAuthorization, jwtToken, vanID, psmID, null); + } + + /** + * @param onlyTables master tables to download (case-insensitive), or null for all of m_downloadmastertable + */ + public String downloadMasterDataFromServer(String ServerAuthorization, String jwtToken, Integer vanID, Integer psmID, + Set onlyTables) throws Exception { if (totalCounter != progressCounter) { return "inProgress"; @@ -103,6 +117,10 @@ public String downloadMasterDataFromServer(String ServerAuthorization, String jw String successFlag = " Master download started "; ArrayList downloadMasterList = syncDownloadMasterRepo.getDownloadTables(); + if (onlyTables != null && downloadMasterList != null) { + downloadMasterList.removeIf(t -> t.getTableName() == null + || !onlyTables.contains(t.getTableName().toLowerCase())); + } totalCounter = downloadMasterList.size(); progressCounter = 0; @@ -178,6 +196,8 @@ public String call() { private int downloadDataFromServer(SyncDownloadMaster syncDownloadMaster,String ServerAuthorization, String jwtToken) throws Exception { int successFlag = 0; + if (stopTBLaptop) + fullDownloadForLineAndVanMasters(syncDownloadMaster); // initializing RestTemplate RestTemplate restTemplate = new RestTemplate(); // Provide the required second argument, e.g., an empty string or appropriate authorization token @@ -210,6 +230,18 @@ private int downloadDataFromServer(SyncDownloadMaster syncDownloadMaster,String return successFlag; } + /** + * StopTB laptops only. Service-line (P) and van (V) masters - hubs, vans, service points, stores, items, item-store mapping, roles - + * are always downloaded in full for this van's line: they are small, and a laptop set up from a copied database + * can carry download dates newer than central rows it never received (missing hub -> its vans fail on the + * foreign key). Rows are inserted/updated by ID, so repeating them is harmless. Masters shared by all lines (A, + * e.g. 661k villages) stay incremental. + */ + static void fullDownloadForLineAndVanMasters(SyncDownloadMaster table) { + if ("P".equalsIgnoreCase(table.getMasterType()) || "V".equalsIgnoreCase(table.getMasterType())) + table.setLastDownloadDate(null); + } + private int updateMastersWithLatestData(MasterDownloadDataDigester masterDownloadDataDigester, SyncDownloadMaster syncDownloadMaster) { int successFlag = 0; @@ -326,6 +358,10 @@ public Map getDownloadStatus() { public int callCentralAPIToGenerateBenIDAndimportToLocal(String requestOBJ, String Authorization, String ServerAuthorization, String token) throws Exception { int i = 0, i1 = 0; + // never run together with the automatic top-up, so the same IDs are not requested twice + if (!BenIdAutoTopUpService.GENERATION_LOCK.tryLock(2, TimeUnit.MINUTES)) { + throw new Exception("BenID generation is already running, please try again in a few minutes"); + } try{ JSONObject originalRequest = new JSONObject(requestOBJ); BigInteger vanID = null; @@ -378,6 +414,8 @@ public int callCentralAPIToGenerateBenIDAndimportToLocal(String requestOBJ, Stri } catch (Exception e) { logger.error("Error while generating catch UNIQUE_ID at central server: " + e.getMessage()); throw new Exception("Error while generating catch UNIQUE_ID at central server: " + e.getMessage()); + } finally { + BenIdAutoTopUpService.GENERATION_LOCK.unlock(); } return i; } diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerTransactionalImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerTransactionalImpl.java index ee3c39a5..e441ea6e 100644 --- a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerTransactionalImpl.java +++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerTransactionalImpl.java @@ -227,8 +227,6 @@ public int downloadTransactionalData(int vanID, String ServerAuthorization, Stri default: } - - i++; } return 1; diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/UploadDataToServerImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/UploadDataToServerImpl.java index d9567861..b0aa115d 100644 --- a/src/main/java/com/iemr/mmu/service/dataSyncActivity/UploadDataToServerImpl.java +++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/UploadDataToServerImpl.java @@ -26,6 +26,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import org.json.JSONArray; import org.json.JSONException; @@ -80,6 +81,11 @@ public class UploadDataToServerImpl implements UploadDataToServer { @Value("${BATCH_SIZE}") private int BATCH_SIZE; + // StopTB camp laptop: a table configured in several sync groups is uploaded only by its last group (see + // skipTablesUploadedByLaterGroup) + @Value("${stoptb.enforce.vanid:false}") + private boolean stopTBLaptop; + @Autowired private DataSyncRepository dataSyncRepository; @Autowired @@ -151,9 +157,33 @@ private String startDataSync(int vanID, String user, String Authorization, Strin String tableKey = obj.getSchemaName() + "." + obj.getTableName(); boolean tableHasError = false; - // get data from DB to sync to server - List> syncData = getDataToSync(obj.getSchemaName(), obj.getTableName(), - obj.getVanColumnName()); + String[] columns = { obj.getVanColumnName(), obj.getServerColumnName() }; + List> syncData; + try { + if (stopTBLaptop) + columns = useRenamedColumns(obj.getTableName(), columns[0], columns[1], + dataSyncRepository.getTableColumns(obj.getSchemaName(), obj.getTableName())); + + // get data from DB to sync to server + syncData = getDataToSync(obj.getSchemaName(), obj.getTableName(), columns[0]); + } catch (Exception e) { + if (!stopTBLaptop) + throw e; + // StopTB camp laptop: one table that cannot be read must not stop the upload of the others; + // its rows stay unprocessed and go on the next upload + logger.error("Upload of table {} skipped: {}", tableKey, e.getMessage()); + groupHasFailures = true; + Map tableDetails = new HashMap<>(); + tableDetails.put("tableName", obj.getTableName()); + tableDetails.put("schemaName", obj.getSchemaName()); + tableDetails.put("status", "failed"); + tableDetails.put("totalRecords", 0); + tableDetails.put("successfulRecords", 0); + tableDetails.put("failedRecords", 0); + tableDetails.put("failureReasons", List.of("Table could not be read: " + e.getMessage())); + tableDetailsList.add(tableDetails); + continue; + } logger.info("Fetched syncData for schema {} and table {}: {}", obj.getSchemaName(), obj.getTableName(), objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(syncData)); @@ -179,7 +209,7 @@ private String startDataSync(int vanID, String user, String Authorization, Strin Map syncResult = syncDataToServer(vanID, obj.getSchemaName(), obj.getTableName(), - obj.getVanAutoIncColumnName(), obj.getServerColumnName(), syncDataBatch, user, + obj.getVanAutoIncColumnName(), columns[1], syncDataBatch, user, Authorization, token); if (syncResult == null) { @@ -221,7 +251,7 @@ private String startDataSync(int vanID, String user, String Authorization, Strin Map syncResult = syncDataToServer(vanID, obj.getSchemaName(), obj.getTableName(), - obj.getVanAutoIncColumnName(), obj.getServerColumnName(), syncDataBatch, user, + obj.getVanAutoIncColumnName(), columns[1], syncDataBatch, user, Authorization, token); if (syncResult == null) { @@ -407,10 +437,59 @@ private String startDataSync(int vanID, String user, String Authorization, Strin private List getVanAndServerColumns(Integer groupID) throws Exception { List syncUtilityClassList = getVanAndServerColumnList(groupID); + if (stopTBLaptop) + syncUtilityClassList = skipTablesUploadedByLaterGroup(syncUtilityClassList, groupID, + syncutilityClassRepo.findByDeletedOrderBySyncTableDetailID(false)); logger.debug("Fetched SyncUtilityClass list for groupID {}: {}", groupID, syncUtilityClassList); return syncUtilityClassList; } + /** + * Inventory tables are configured twice: group 5 (old lists without the store columns of stock-outs and patient + * issues, batch insert fails) and group 9 "Inventory" (complete). Group 5 ran first and marked the rows uploaded, + * so central got stock-outs without their camp store. A table that a later group also uploads is skipped here. + */ + static List skipTablesUploadedByLaterGroup(List groupTables, int groupID, + List allTables) { + Map lastGroup = new HashMap<>(); + for (SyncUtilityClass t : allTables) { + if (t.getTableName() != null && t.getSyncTableGroupID() != null) + lastGroup.merge(t.getTableName().toLowerCase(), t.getSyncTableGroupID(), Math::max); + } + List kept = new ArrayList<>(); + for (SyncUtilityClass t : groupTables) { + Integer last = t.getTableName() == null ? null : lastGroup.get(t.getTableName().toLowerCase()); + if (last == null || last <= groupID) + kept.add(t); + } + return kept; + } + + // columns renamed by DB scripts after the sync table lists were written (tb_diagnostic_order: V114 renamed + // reason_for_refusal to reason_to_close, V117 reverted it, V125 renamed it again) + private static final Map> RENAMED_COLUMNS = Map.of("tb_diagnostic_order", + Map.of("reason_for_refusal", "reason_to_close", "reason_to_close", "reason_for_refusal")); + + /** + * The sync table list may still name a column by its old name (laptop m_synctabledetail has reason_for_refusal, + * the table has reason_to_close) and the whole upload stopped with "bad SQL grammar". The name the table really + * has is used in both the van and the server column lists (central has the same DB version). + */ + static String[] useRenamedColumns(String tableName, String vanColumns, String serverColumns, + Set tableColumns) { + Map renamed = tableName == null ? null : RENAMED_COLUMNS.get(tableName.toLowerCase()); + if (renamed == null || tableColumns == null || tableColumns.isEmpty()) + return new String[] { vanColumns, serverColumns }; + for (Map.Entry r : renamed.entrySet()) { + if (!tableColumns.contains(r.getKey()) && tableColumns.contains(r.getValue())) { + String listed = "(?i)\\b" + r.getKey() + "\\b"; + vanColumns = vanColumns == null ? null : vanColumns.replaceAll(listed, r.getValue()); + serverColumns = serverColumns == null ? null : serverColumns.replaceAll(listed, r.getValue()); + } + } + return new String[] { vanColumns, serverColumns }; + } + public List getVanAndServerColumnList(Integer groupID) throws Exception { List syncUtilityClassList = syncutilityClassRepo .findBySyncTableGroupIDAndDeletedOrderBySyncTableDetailID(groupID, false); diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentral.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentral.java index 0da5bf4b..75aa52a4 100644 --- a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentral.java +++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentral.java @@ -173,6 +173,22 @@ public int checkRecordIsAlreadyPresentOrNot(String schemaName, String tableName, } } + /** + * Central ID of a row a van uploaded earlier, found by the van's own ID (VanSerialNo) and the van; null if that + * row is not on central (yet). + */ + public Long findCentralIdOfVanRow(String schemaName, String tableName, String idColumn, Object vanSerialNo, + String vanID) { + if (!isValidSchemaName(schemaName) || !isValidDatabaseIdentifierCharacter(tableName) + || !isValidDatabaseIdentifierCharacter(idColumn)) { + throw new IllegalArgumentException("Invalid identifiers provided."); + } + List ids = getJdbcTemplate().queryForList("SELECT " + idColumn + " FROM " + schemaName + "." + tableName + + " WHERE VanSerialNo = ? AND VanID = ? ORDER BY " + idColumn + " DESC LIMIT 1", Long.class, + String.valueOf(vanSerialNo), vanID); + return ids.isEmpty() ? null : ids.get(0); + } + public int[] syncDataToCentralDB(String schema, String tableName, String serverColumns, String query, List syncDataList) { jdbcTemplate = getJdbcTemplate(); diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentralDownload.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentralDownload.java index c76962b4..50c49c0e 100644 --- a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentralDownload.java +++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DataSyncRepositoryCentralDownload.java @@ -205,7 +205,8 @@ else if (masterType.equalsIgnoreCase("P")) { } logger.info("Select query central: " + baseQuery); logger.info("Last Downloaded Date " + lastDownloadDate); - logger.info("Result set Details: " + resultSetList); + // Row count only: logging the rows of a large master (villages, 661k rows) exhausted the heap. + logger.info("Rows returned: " + resultSetList.size()); return resultSetList; } diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentFetchService.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentFetchService.java new file mode 100644 index 00000000..a03526f9 --- /dev/null +++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentFetchService.java @@ -0,0 +1,62 @@ +package com.iemr.mmu.service.dataSyncLayerCentral; + +import java.time.Duration; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +/*** + * @purpose Hands back a short-lived presigned URL for the latest successfully-pushed + * diagnostic document matching a beneficiary+documentType, generated on demand from + * the object's S3 key (s3_path) - the bucket is private, so no permanent URL is + * ever persisted or handed out. + */ +@Service +public class DiagnosticDocumentFetchService { + + private static final Duration URL_VALIDITY = Duration.ofMinutes(15); + + @Value("${diagnostic.documents.s3.bucket}") + private String bucket; + + @Autowired + private DiagnosticDocumentRepository diagnosticDocumentRepository; + + @Autowired + private S3Presigner s3Presigner; + + /*** + * @return null if no successfully-pushed document matches, otherwise the download details + * (documentType, orderType, externalOrderId, contentType, lastModDate, downloadUrl, + * urlExpiresInSeconds) + */ + public Map getLatestDocumentDownload(Long beneficiaryId, String documentType) { + Map row = diagnosticDocumentRepository.findLatestDocument(beneficiaryId, documentType); + if (row == null) { + return null; + } + + String s3Key = (String) row.get("s3_path"); + GetObjectRequest getObjectRequest = GetObjectRequest.builder().bucket(bucket).key(s3Key).build(); + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder().signatureDuration(URL_VALIDITY) + .getObjectRequest(getObjectRequest).build(); + String downloadUrl = s3Presigner.presignGetObject(presignRequest).url().toString(); + + Map result = new java.util.HashMap<>(); + result.put("externalOrderId", row.get("external_order_id")); + result.put("orderType", row.get("order_type")); + result.put("documentType", row.get("document_type")); + result.put("contentType", row.get("content_type")); + result.put("originalFileName", row.get("original_file_name")); + result.put("lastModDate", String.valueOf(row.get("last_mod_date"))); + result.put("downloadUrl", downloadUrl); + result.put("urlExpiresInSeconds", URL_VALIDITY.getSeconds()); + return result; + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentIngestService.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentIngestService.java new file mode 100644 index 00000000..d04d841f --- /dev/null +++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentIngestService.java @@ -0,0 +1,139 @@ +package com.iemr.mmu.service.dataSyncLayerCentral; + +import java.lang.reflect.Type; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; + +/*** + * @purpose Receives a batch of decrypted diagnostic documents pushed from a van and stores + * each in S3 - purely a storage relay, no database writes here. The pushing van + * persists its own record locally (DiagnosticDocumentPushServiceImpl.markPushedToCentral, + * keyed off the s3Path this returns in each ack) - the central server's own database is + * left untouched. + */ +@Service +public class DiagnosticDocumentIngestService { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName()); + private static final Gson GSON = new Gson(); + private static final String ENCRYPTED_FILE_SUFFIX = ".enc"; + + @Value("${diagnostic.documents.s3.bucket}") + private String bucket; + + @Autowired + private S3Client s3Client; + + public String ingestDocuments(String requestOBJ) throws Exception { + Type listType = new TypeToken>>() { + }.getType(); + List> items = GSON.fromJson(requestOBJ, listType); + if (items == null || items.isEmpty()) { + return GSON.toJson(new ArrayList<>()); + } + + List> acks = new ArrayList<>(); + for (Map item : items) { + acks.add(ingestOne(item)); + } + return GSON.toJson(acks); + } + + private Map ingestOne(Map item) { + Map ack = new HashMap<>(); + ack.put("documentId", item.get("documentId")); + ack.put("diagnosticOrderId", item.get("diagnosticOrderId")); + ack.put("externalOrderId", item.get("externalOrderId")); + ack.put("documentType", item.get("documentType")); + + try { + ack.put("documentId", asLong(item.get("documentId"))); + Long diagnosticOrderId = asLong(item.get("diagnosticOrderId")); + ack.put("diagnosticOrderId", diagnosticOrderId); + String documentType = (String) item.get("documentType"); + + byte[] plaintext = Base64.getDecoder().decode((String) item.get("fileContentBase64")); + + String sha256Hash = (String) item.get("sha256Hash"); + if (sha256Hash != null && !sha256Hash.equalsIgnoreCase(sha256Hex(plaintext))) { + ack.put("status", "FAILED"); + ack.put("error", "sha256 mismatch on receipt"); + return ack; + } + + Long beneficiaryId = asLong(item.get("beneficiaryId")); + Long vanId = asLong(item.get("vanID")); + String orderType = (String) item.get("orderType"); + String storedFileName = (String) item.get("storedFileName"); + if (vanId == null || beneficiaryId == null || orderType == null || documentType == null + || storedFileName == null) { + ack.put("status", "FAILED"); + ack.put("error", "Missing vanID, beneficiaryId, orderType, documentType or storedFileName"); + return ack; + } + String s3Key = vanId + "/" + beneficiaryId + "/" + orderType + "/" + documentType + "/" + + objectFileName(storedFileName, (String) item.get("fileExtension")); + String contentType = (String) item.get("contentType"); + + s3Client.putObject( + PutObjectRequest.builder().bucket(bucket).key(s3Key) + .contentType(contentType != null ? contentType : "application/octet-stream") + .serverSideEncryption(ServerSideEncryption.AES256).build(), + RequestBody.fromBytes(plaintext)); + + ack.put("status", "SUCCESS"); + ack.put("s3Path", s3Key); + } catch (Exception e) { + logger.error("Error ingesting diagnostic document: documentId=" + item.get("documentId") + + ", diagnosticOrderId=" + item.get("diagnosticOrderId") + + ", documentType=" + item.get("documentType"), e); + ack.put("status", "FAILED"); + ack.put("error", e.getMessage()); + } + return ack; + } + + /*** + * @purpose The van stores files encrypted as ".enc", but S3 holds the decrypted + * content - so the object is named with the real extension the van derived from + * its content type (e.g. CAD.enc -> CAD.pdf), not the misleading ".enc". + */ + private static String objectFileName(String storedFileName, String fileExtension) { + String baseName = storedFileName.endsWith(ENCRYPTED_FILE_SUFFIX) + ? storedFileName.substring(0, storedFileName.length() - ENCRYPTED_FILE_SUFFIX.length()) + : storedFileName; + return fileExtension != null && !fileExtension.isEmpty() ? baseName + "." + fileExtension : baseName; + } + + private static Long asLong(Object value) { + return value == null ? null : ((Number) value).longValue(); + } + + private static String sha256Hex(byte[] data) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(data); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(String.format(Locale.ROOT, "%02x", b)); + } + return hex.toString(); + } +} diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentPushServiceImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentPushServiceImpl.java new file mode 100644 index 00000000..cc817200 --- /dev/null +++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentPushServiceImpl.java @@ -0,0 +1,274 @@ +package com.iemr.mmu.service.dataSyncLayerCentral; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.reflect.TypeToken; +import com.iemr.mmu.utils.CryptoUtil; +import com.iemr.mmu.utils.RestTemplateUtil; + +import java.lang.reflect.Type; + +/*** + * @purpose Reads MMU-API's own locally-pending diagnostic documents (docsProcessed='N', + * shared db_iemr.tb_diagnostic_document table), decrypts the file off the shared + * filesystem, and pushes each batch to the further central server's + * /dataSync/diagnostic-documents endpoint - the same relay shape as + * UploadDataToServerImpl's push to dataSyncUploadUrl, just for this table instead + * of the generic sync-group config. MMU-API's own /dataSync/diagnostic-documents + * ingest endpoint (DiagnosticDocumentCentralIngestService) is untouched by this - + * this service is a separate outbound relay, not a caller of it. + */ +@Service +public class DiagnosticDocumentPushServiceImpl { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName()); + + private static final Map CONTENT_TYPE_EXTENSIONS = new HashMap<>(); + static { + CONTENT_TYPE_EXTENSIONS.put("application/pdf", "pdf"); + CONTENT_TYPE_EXTENSIONS.put("image/jpeg", "jpg"); + CONTENT_TYPE_EXTENSIONS.put("image/png", "png"); + } + + @Value("${diagnostic.documents.storage-root}") + private String storageRoot; + + @Value("${diagnosticDocumentUploadUrl}") + private String diagnosticDocumentUploadUrl; + + @Value("${diagnosticDocument.push.batchSize:3}") + private int batchSize; + + @Autowired + private DiagnosticDocumentRepository diagnosticDocumentRepository; + + @Autowired + private CryptoUtil cryptoUtil; + + public String pushPendingDocuments(String Authorization) throws Exception { + List> pendingRows = diagnosticDocumentRepository.findPendingDocuments(); + int totalSucceeded = 0; + List failureReasons = new ArrayList<>(); + + for (int offset = 0; offset < pendingRows.size(); offset += batchSize) { + List> rows = pendingRows.subList(offset, Math.min(offset + batchSize, pendingRows.size())); + + List> payloadItems = new ArrayList<>(); + Map> rowsById = new HashMap<>(); + for (Map row : rows) { + Long rowId = asLong(row.get("id")); + String base64Plaintext; + try { + String storedPath = (String) row.get("stored_path"); + Path filePath = Paths.get(storageRoot, storedPath); + String encryptedPayload = new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8); + base64Plaintext = cryptoUtil.decrypt(encryptedPayload); + } catch (Exception e) { + logger.warn("Skipping diagnostic document push, could not read file off disk: id={}, error={}", + rowId, e.getMessage()); + markFailed(rowId, "Could not read file off shared filesystem: " + e.getMessage(), failureReasons); + continue; + } + if (base64Plaintext == null) { + logger.warn("Skipping diagnostic document push, decrypt failed: id={}", rowId); + markFailed(rowId, "Decrypt failed", failureReasons); + continue; + } + + Long diagnosticOrderId = asLong(row.get("diagnostic_order_id")); + String externalOrderId = (String) row.get("external_order_id"); + String documentType = (String) row.get("document_type"); + String contentType = (String) row.get("content_type"); + + Map item = new HashMap<>(); + item.put("documentId", rowId); + item.put("diagnosticOrderId", diagnosticOrderId); + item.put("externalOrderId", externalOrderId); + item.put("beneficiaryId", row.get("beneficiary_id")); + item.put("orderType", row.get("order_type")); + item.put("documentType", documentType); + item.put("storedFileName", row.get("stored_file_name")); + item.put("sha256Hash", row.get("sha256_hash")); + item.put("contentType", contentType); + item.put("fileExtension", extensionFor(contentType)); + item.put("originalFileName", row.get("original_file_name")); + item.put("vanID", row.get("vanID")); + item.put("parkingPlaceID", row.get("parkingPlaceID")); + item.put("vanSerialNo", row.get("vanSerialNo")); + item.put("fileContentBase64", base64Plaintext); + payloadItems.add(item); + rowsById.put(rowId, row); + } + + if (payloadItems.isEmpty()) { + continue; + } + + String requestOBJ = new Gson().toJson(payloadItems); + List> acks; + try { + HttpEntity request = RestTemplateUtil.createRequestEntity(requestOBJ, Authorization, "datasync"); + RestTemplate restTemplate = new RestTemplate(); + ResponseEntity response = restTemplate.exchange(diagnosticDocumentUploadUrl, HttpMethod.POST, + request, String.class); + + if (response == null || !response.hasBody()) { + logger.warn("No response body from central server for diagnostic document push, marking batch failed"); + markBatchFailed(rowsById, "No response body from central server", failureReasons); + continue; + } + + // Central server wraps every response in the shared OutputResponse envelope + // ({"data": [...], "statusCode":200, ...}) - the ack array lives under "data". + JsonElement parsedBody = JsonParser.parseString(response.getBody()); + if (!parsedBody.isJsonObject()) { + logger.warn("Unexpected response shape from central server for diagnostic document push, marking batch failed"); + markBatchFailed(rowsById, "Unexpected response shape from central server", failureReasons); + continue; + } + JsonObject envelope = parsedBody.getAsJsonObject(); + if (!envelope.has("statusCode") || envelope.get("statusCode").getAsInt() != 200 + || !envelope.has("data")) { + logger.warn("Central server reported failure for diagnostic document push batch, marking batch failed: {}", + response.getBody()); + markBatchFailed(rowsById, "Central server reported failure: " + response.getBody(), failureReasons); + continue; + } + + Type ackListType = new TypeToken>>() { + }.getType(); + acks = new Gson().fromJson(envelope.get("data"), ackListType); + if (acks == null) { + markBatchFailed(rowsById, "Central server returned no acknowledgements", failureReasons); + continue; + } + } catch (Exception e) { + logger.error("Error calling central server for diagnostic document push, marking batch failed", e); + markBatchFailed(rowsById, "Error calling central server: " + e.getMessage(), failureReasons); + continue; + } + + int batchSuccessCount = 0; + Map> unmatchedRowsById = new HashMap<>(rowsById); + for (Map ack : acks) { + Long rowId = ack.get("documentId") instanceof Number ? asLong(ack.get("documentId")) : null; + if (rowId == null || unmatchedRowsById.remove(rowId) == null) { + continue; + } + if ("SUCCESS".equalsIgnoreCase((String) ack.get("status"))) { + diagnosticDocumentRepository.markPushedToCentral(rowId, (String) ack.get("s3Path")); + batchSuccessCount++; + } else { + String reason = (String) ack.get("error"); + logger.warn("Central server rejected diagnostic document push: id={}, externalOrderId={}, documentType={}, error={}", + rowId, ack.get("externalOrderId"), ack.get("documentType"), reason); + markFailed(rowId, reason != null ? reason : "Central server rejected the document", failureReasons); + } + } + if (!unmatchedRowsById.isEmpty()) { + // Central sent back fewer acks than documents we sent - whatever wasn't + // accounted for must not be silently left at its previous status forever. + logger.warn( + "Diagnostic document push: {} row(s) in this batch got no matching ack back, marking failed", + unmatchedRowsById.size()); + markBatchFailed(unmatchedRowsById, "No acknowledgement received from central server", failureReasons); + } + + totalSucceeded += batchSuccessCount; + logger.info("Diagnostic document push batch complete: attempted={}, succeeded={}", payloadItems.size(), + batchSuccessCount); + } + + if (pendingRows.isEmpty()) { + return "No data to sync"; + } + + // Same table-level summary shape as /van-to-server (UploadDataToServerImpl) so the + // data sync screen can read both the same way. + int totalRecords = pendingRows.size(); + int failedRecords = failureReasons.size(); + String status; + if (failedRecords == 0) { + status = "success"; + } else if (totalSucceeded == 0) { + status = "failed"; + } else { + status = "partial"; + } + + Map finalResponse = new LinkedHashMap<>(); + finalResponse.put("response", + "success".equals(status) ? "Data sync completed successfully" : "Data sync completed with failures"); + finalResponse.put("schemaName", "db_iemr"); + finalResponse.put("tableName", "tb_diagnostic_document"); + finalResponse.put("status", status); + finalResponse.put("totalRecords", totalRecords); + finalResponse.put("successfulRecords", totalSucceeded); + finalResponse.put("failedRecords", failedRecords); + if (!failureReasons.isEmpty()) { + finalResponse.put("failureReasons", failureReasons); + } + + logger.info("Diagnostic document push complete: {} (Success: {}, Failed: {}, Total: {})", status, + totalSucceeded, failedRecords, totalRecords); + return new Gson().toJson(finalResponse); + } + + /*** + * @purpose Marks one row failed locally and records its reason for the run summary. + */ + private void markFailed(Long rowId, String reason, List failureReasons) { + diagnosticDocumentRepository.markPushFailed(rowId, reason); + failureReasons.add("documentId " + rowId + ": " + reason); + } + + /*** + * @purpose Called when the whole batch call to the central server fails (no/garbled + * response, non-200, or a thrown exception) - marks every row that was in this + * batch as failed, matching how /van-to-server marks a whole batch failed on a + * connection error, rather than leaving them stuck at docsProcessed='N' forever. + */ + private void markBatchFailed(Map> rowsById, String reason, List failureReasons) { + for (Long rowId : rowsById.keySet()) { + markFailed(rowId, reason, failureReasons); + } + } + + private static Long asLong(Object value) { + return value == null ? null : ((Number) value).longValue(); + } + + private static String extensionFor(String contentType) { + if (contentType == null) { + return "bin"; + } + String extension = CONTENT_TYPE_EXTENSIONS.get(contentType.toLowerCase()); + if (extension != null) { + return extension; + } + int slashIndex = contentType.indexOf('/'); + return slashIndex >= 0 && slashIndex < contentType.length() - 1 ? contentType.substring(slashIndex + 1) : "bin"; + } +} diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentRepository.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentRepository.java new file mode 100644 index 00000000..53c30c5f --- /dev/null +++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/DiagnosticDocumentRepository.java @@ -0,0 +1,95 @@ +package com.iemr.mmu.service.dataSyncLayerCentral; + +import java.util.List; +import java.util.Map; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/*** + * @purpose Local (van) side persistence for the diagnostic-document push pipeline - the central + * server is a pure S3 storage relay (DiagnosticDocumentCentralIngestService) and writes + * nothing to its own database, so this repository only ever operates on the pushing + * van's own local db_iemr.tb_diagnostic_document rows. + */ +@Service +public class DiagnosticDocumentRepository { + + @Autowired + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + private JdbcTemplate getJdbcTemplate() { + if (this.jdbcTemplate == null) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + return this.jdbcTemplate; + } + + /*** + * @purpose Latest successfully-pushed document for a beneficiary+documentType, used to hand + * back a fresh presigned URL on demand instead of persisting a permanent (and, for a + * private bucket, non-functional) URL. s3_path holds the S3 key for a + * docsProcessed='P' row (markPushedToCentral() below writes it there). + */ + public Map findLatestDocument(Long beneficiaryId, String documentType) { + String query = "SELECT id, external_order_id, order_type, document_type, s3_path, content_type, " + + "original_file_name, last_mod_date FROM db_iemr.tb_diagnostic_document " + + "WHERE beneficiary_id = ? AND document_type = ? AND docsProcessed = 'P' " + + "ORDER BY last_mod_date DESC, id DESC LIMIT 1"; + List> rows = getJdbcTemplate().queryForList(query, beneficiaryId, documentType); + return rows.isEmpty() ? null : rows.get(0); + } + + /*** + * @purpose Snapshot of every locally-pending row's metadata (docsProcessed='N' or 'F') at + * the start of a push run, taken in one query so a row this same run marks 'F' + * partway through (e.g. an auth failure) is never re-picked-up later in the same + * run - it only becomes eligible again on the next trigger, alongside whatever's + * newly 'N' by then. Only metadata is loaded here (id, hashes, filenames, etc.) - + * each row's actual file content is still read off disk and decrypted one batch at + * a time by the caller, so the whole backlog's decrypted content is never held in + * memory at once. + */ + public List> findPendingDocuments() { + String query = "SELECT id, diagnostic_order_id, external_order_id, beneficiary_id, order_type, document_type, " + + "stored_file_name, stored_path, sha256_hash, content_type, original_file_name, " + + "vanID, parkingPlaceID, vanSerialNo FROM db_iemr.tb_diagnostic_document " + + "WHERE docsProcessed = 'N' OR docsProcessed = 'F' ORDER BY id ASC"; + return getJdbcTemplate().queryForList(query); + } + + /*** + * @purpose Marks a document as successfully pushed on the LOCAL (van) row - the central + * server itself writes nothing to its own database. s3Path is the S3 key the + * central server's ack reported back, persisted here so the van's own local DB + * knows where the document ended up, not just that it did. + */ + public void markPushedToCentral(Long id, String s3Path) { + String update = "UPDATE db_iemr.tb_diagnostic_document SET processed = 'N', docsProcessed = 'P', " + + "s3_path = ?, docSyncedDate = NOW(), docSyncFailureReason = NULL, last_mod_date = NOW() WHERE id = ?"; + getJdbcTemplate().update(update, s3Path, id); + } + + /*** + * @purpose Marks a document push to the further central server as failed - docsProcessed='F', + * matching the P/F status convention used by the generic sync pipeline's + * DataSyncRepository.updateProcessedFlagInVan for /van-to-server. Persists why in + * docSyncFailureReason (mirroring the generic pipeline's own SyncFailureReason, kept + * separate since this pipeline's docsProcessed/docSyncedDate are their own dedicated + * columns) so a failure isn't only visible in the application log. Also clears + * s3_path - otherwise a row that succeeded once, then got re-attempted and failed, + * would keep showing a stale S3 key while docsProcessed says 'F'. docSyncedDate is + * left untouched - it records the last time this row was actually confirmed synced, + * if ever. + */ + public void markPushFailed(Long id, String reason) { + String update = "UPDATE db_iemr.tb_diagnostic_document SET processed = 'N', docsProcessed = 'F', " + + "s3_path = NULL, docSyncFailureReason = ?, last_mod_date = NOW() WHERE id = ?"; + getJdbcTemplate().update(update, reason, id); + } +} diff --git a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBImpl.java index c0e508cd..4db30fff 100644 --- a/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBImpl.java +++ b/src/main/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBImpl.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -53,6 +54,21 @@ public class GetDataFromVanAndSyncToDBImpl implements GetDataFromVanAndSyncToDB @Value("${downSync.markDeliveredOnUpSync:true}") private boolean markDownSyncedOnUpSync; + // fill VanSerialNo / SyncFacilityID from the van row when not sent by name (see fillVanKeys) + @Value("${dataSync.fillVanKeys:true}") + private boolean fillVanKeysEnabled = true; + + // replace van IDs in link columns by central IDs (see translateVanLinks); off by default, on for StopTB central + @Value("${dataSync.translateVanLinks:false}") + private boolean translateVanLinksEnabled; + + // link column -> {table it points to, that table's ID column} + private static final Map VAN_LINKS = new LinkedHashMap<>(); + static { + VAN_LINKS.put("BenVisitID", new String[] { "t_benvisitdetail", "BenVisitID" }); + VAN_LINKS.put("PrescriptionID", new String[] { "t_prescription", "PrescriptionID" }); + } + private static final Map> TABLE_GROUPS = new HashMap<>(); static { TABLE_GROUPS.put(1, @@ -372,7 +388,21 @@ private boolean performGenericTableSync(SyncUploadDataDigester syncUploadDataDig // CRITICAL FIX: Keep null as null, don't convert to string cleanRecord.put(cleanKey.trim(), value); } - + if (fillVanKeysEnabled) + fillVanKeys(cleanRecord, serverColumnsList, vanAutoIncColumnName); + if (translateVanLinksEnabled) { + Object linkVanID = cleanRecord.get("VanID") != null ? cleanRecord.get("VanID") : cleanRecord.get("vanID"); + String linkError = translateVanLinks(schemaName, syncTableName, cleanRecord, serverColumnsList, + linkVanID == null ? null : String.valueOf(linkVanID)); + if (linkError != null) { + syncResults.add(new SyncResult(schemaName, syncTableName, + String.valueOf(cleanRecord.get(vanAutoIncColumnName)), syncUploadDataDigester.getSyncedBy(), + false, linkError)); + overallSuccess = false; + continue; + } + } + String vanSerialNo = String.valueOf(cleanRecord.get(vanAutoIncColumnName)); // Column may be "vanID" (Stop TB tables) or "VanID" (standard tables) — check both @@ -606,6 +636,57 @@ private boolean performGenericTableSync(SyncUploadDataDigester syncUploadDataDig return overallSuccess; } + /** + * m_synctabledetail maps van columns to server columns by position: the van's own ID (VanAutoIncColumnName, + * e.g. BenVisitID, ItemStockEntryID) sits where the server list has VanSerialNo. Records are filled by name, so + * without this VanSerialNo stays NULL: the row cannot be matched on the next upload (duplicates) and joins on + * VanSerialNo break. Same for SyncFacilityID, which some van lists do not send: it is the row's store. + */ + static void fillVanKeys(Map record, List serverColumns, String vanAutoIncColumnName) { + if (hasColumn(serverColumns, "VanSerialNo") && record.get("VanSerialNo") == null + && vanAutoIncColumnName != null && record.get(vanAutoIncColumnName) != null) { + record.put("VanSerialNo", record.get(vanAutoIncColumnName)); + } + if (hasColumn(serverColumns, "SyncFacilityID") && record.get("SyncFacilityID") == null + && record.get("FacilityID") != null) { + record.put("SyncFacilityID", record.get("FacilityID")); + } + } + + /** + * A van sends its own IDs in link columns (BenVisitID, PrescriptionID). On central those numbers belong to other + * rows (other vans, central data): the link pointed to the wrong visit/prescription, or the foreign key rejected + * the row. The link is replaced by the central ID of the row the van uploaded with that VanSerialNo. If that row + * is not on central yet, the record is not saved (returned reason) and is sent again on the next upload. + * + * @return null when done, or the reason the record cannot be saved yet + */ + String translateVanLinks(String schemaName, String tableName, Map record, + List serverColumns, String vanID) { + for (Map.Entry link : VAN_LINKS.entrySet()) { + String column = link.getKey(); + String parentTable = link.getValue()[0]; + Object vanValue = record.get(column); + if (vanValue == null || vanID == null || tableName.equalsIgnoreCase(parentTable) + || !hasColumn(serverColumns, column)) + continue; + Long centralID = dataSyncRepositoryCentral.findCentralIdOfVanRow(schemaName, parentTable, + link.getValue()[1], vanValue, vanID); + if (centralID == null) + return "Linked " + parentTable + " (van ID " + vanValue + ") not on central yet; sent again on next upload"; + record.put(column, centralID); + } + return null; + } + + private static boolean hasColumn(List columns, String name) { + for (String column : columns) { + if (column.trim().equals(name)) + return true; + } + return false; + } + private void flagUpSyncedRecordsForDownSync(String schemaName, String tableName, List syncResults, Map vanIDByResultIndex) { if (!markDownSyncedOnUpSync || vanIDByResultIndex.isEmpty()) diff --git a/src/main/java/com/iemr/mmu/utils/CryptoUtil.java b/src/main/java/com/iemr/mmu/utils/CryptoUtil.java new file mode 100644 index 00000000..c964fdf2 --- /dev/null +++ b/src/main/java/com/iemr/mmu/utils/CryptoUtil.java @@ -0,0 +1,42 @@ +package com.iemr.mmu.utils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * Decrypts diagnostic-document files written by FLW-API's own CryptoUtil + * (same AES/ECB/PKCS5Padding scheme and key) so they can be read directly + * off the shared filesystem before pushing to the further central server. + */ +@Service +public class CryptoUtil { + + private static final Logger logger = LoggerFactory.getLogger(CryptoUtil.class); + private static final String ALGORITHM = "AES"; + private static final String SECRET_KEY = "dev-envro-secret"; + + public String decrypt(String encryptedValue) { + try { + SecretKey secretKey = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedValue)); + return removePadding(new String(decryptedBytes, StandardCharsets.UTF_8)); + } catch (Exception e) { + logger.error("Exception while decrypting diagnostic document", e); + return null; + } + } + + private String removePadding(String value) { + int paddingLength = value.charAt(value.length() - 1); + return value.substring(0, value.length() - paddingLength); + } +} diff --git a/src/test/java/com/iemr/mmu/service/benid/BenIdAutoTopUpServiceTest.java b/src/test/java/com/iemr/mmu/service/benid/BenIdAutoTopUpServiceTest.java new file mode 100644 index 00000000..c10e6230 --- /dev/null +++ b/src/test/java/com/iemr/mmu/service/benid/BenIdAutoTopUpServiceTest.java @@ -0,0 +1,272 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.service.benid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.ExpectedCount; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +class BenIdAutoTopUpServiceTest { + + private static final String GENERATE = "http://central/common-api/beneficiary/generateBeneficiaryIDs"; + private static final String IMPORT = "http://local/id/saveGeneratedBenIDToLocalServer"; + private static final String COUNT = "http://local/id/checkAvailablBenIDLocalServer"; + + @TempDir + Path dataDir; + + private BenIdAutoTopUpService service; + private CentralAuthService centralAuth; + private MockRestServiceServer server; + + @BeforeEach + void setUp() throws Exception { + centralAuth = mock(CentralAuthService.class); + when(centralAuth.isEnabled()).thenReturn(true); + when(centralAuth.getVanID()).thenReturn(403); + when(centralAuth.getState()).thenReturn(CentralAuthService.State.CONNECTED); + when(centralAuth.getCentralSessionKey()).thenReturn("SESSION-KEY"); + + service = new BenIdAutoTopUpService(); + ReflectionTestUtils.setField(service, "centralAuthService", centralAuth); + ReflectionTestUtils.setField(service, "benGenUrlCentral", GENERATE); + ReflectionTestUtils.setField(service, "benImportUrlLocal", IMPORT); + ReflectionTestUtils.setField(service, "benCheckUrlLocal", COUNT); + ReflectionTestUtils.setField(service, "lowerLimit", 2000L); + ReflectionTestUtils.setField(service, "batchSize", 1000L); + ReflectionTestUtils.setField(service, "bootstrapSize", 10000L); + ReflectionTestUtils.setField(service, "maxPerRequest", 1000L); + ReflectionTestUtils.setField(service, "dataDir", dataDir.toString()); + ReflectionTestUtils.setField(service, "retryDelaysMs", new long[] { 0L }); + ReflectionTestUtils.setField(service, "connectTimeoutMs", 1000); + ReflectionTestUtils.setField(service, "readTimeoutMs", 1000); + service.init(); + server = MockRestServiceServer.bindTo((RestTemplate) ReflectionTestUtils.getField(service, "restTemplate")) + .build(); + } + + @AfterEach + void releaseLock() { + while (BenIdAutoTopUpService.GENERATION_LOCK.isHeldByCurrentThread()) { + BenIdAutoTopUpService.GENERATION_LOCK.unlock(); + } + } + + private void expectCount(long n) { + server.expect(requestTo(COUNT)) + .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"response\":\"" + n + "\"}}", + MediaType.APPLICATION_JSON)); + } + + private static String ids(int n) { + StringBuilder sb = new StringBuilder("["); + for (int i = 1; i <= n; i++) { + sb.append(i > 1 ? "," : "").append("{\"benRegId\":").append(5000 + i) + .append(",\"beneficiaryId\":").append(900000000000L + i).append("}"); + } + return sb.append("]").toString(); + } + + private void expectGenerate(long requested, int returned) { + server.expect(requestTo(GENERATE)).andExpect(header("Authorization", "SESSION-KEY")) + .andExpect(content().string(Matchers.containsString("\"benIDRequired\":" + requested))) + .andExpect(content().string(Matchers.containsString("\"vanID\":403"))) + .andRespond(withSuccess("{\"statusCode\":200,\"data\":" + ids(returned) + "}", + MediaType.APPLICATION_JSON)); + } + + private void expectImportOk() { + server.expect(requestTo(IMPORT)).andExpect(content().string(Matchers.containsString("\"vanID\":403"))) + .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"response\":\"ok\"}}", + MediaType.APPLICATION_JSON)); + } + + private void run() { + ReflectionTestUtils.invokeMethod(service, "runOnce", true); + } + + private Path pending() { + return dataDir.resolve("benid-pending-import.json"); + } + + @Test + void doesNothingWhenDisabled() throws Exception { + when(centralAuth.isEnabled()).thenReturn(false); + server.expect(ExpectedCount.never(), requestTo(COUNT)); + run(); + server.verify(); + verify(centralAuth, never()).getCentralSessionKey(); + } + + @Test + void emptyPoolGetsBootstrapSize() { + ReflectionTestUtils.setField(service, "bootstrapSize", 800L); + expectCount(0); + expectGenerate(800, 3); + expectImportOk(); + expectCount(3); + run(); + server.verify(); + assertFalse(Files.exists(pending()), "pending file removed after a successful import"); + } + + @Test + void lowPoolGetsBatchSize() { + expectCount(1999); + expectGenerate(1000, 2); + expectImportOk(); + expectCount(2001); + run(); + server.verify(); + } + + @Test + void healthyPoolDoesNotCallCentral() throws Exception { + expectCount(2000); + server.expect(ExpectedCount.never(), requestTo(GENERATE)); + run(); + server.verify(); + verify(centralAuth, never()).getCentralSessionKey(); + } + + @Test + void noCentralTokenWaitsForLogin() throws Exception { + when(centralAuth.getCentralSessionKey()).thenReturn(null); + expectCount(0); + server.expect(ExpectedCount.never(), requestTo(GENERATE)); + run(); + server.verify(); + assertTrue(((String) ReflectionTestUtils.getField(service, "lastError")).contains("waiting for a user login")); + } + + @Test + void centralErrorLeavesNothingBehind() { + expectCount(0); + server.expect(requestTo(GENERATE)).andRespond(withStatus(HttpStatus.GATEWAY_TIMEOUT)); + run(); + server.verify(); + assertFalse(Files.exists(pending())); + assertTrue(ReflectionTestUtils.getField(service, "lastError") != null); + } + + @Test + void failedLocalImportKeepsIdsAndImportsThemNextRun() throws Exception { + ReflectionTestUtils.setField(service, "bootstrapSize", 800L); + expectCount(0); + expectGenerate(800, 3); + server.expect(requestTo(IMPORT)).andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)); + // next run: pending IDs are imported first, then the pool is healthy + expectImportOk(); + expectCount(2500); + run(); + assertTrue(Files.exists(pending()), "IDs received from central must be kept"); + assertEquals(3, Files.readString(pending()).split("benRegId").length - 1); + + run(); + server.verify(); + assertFalse(Files.exists(pending())); + } + + @Test + void skipsWhileManualGenerationRuns() { + BenIdAutoTopUpService.GENERATION_LOCK.lock(); + try { + Thread other = new Thread(this::run); + server.expect(ExpectedCount.never(), requestTo(COUNT)); + other.start(); + other.join(5000); + server.verify(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + BenIdAutoTopUpService.GENERATION_LOCK.unlock(); + } + } + + @Test + void bigDownloadIsSplitIntoRequestsOfAtMost1000() { + ReflectionTestUtils.setField(service, "bootstrapSize", 2500L); + expectCount(0); + expectGenerate(1000, 2); + expectImportOk(); + expectGenerate(1000, 2); + expectImportOk(); + expectGenerate(500, 2); + expectImportOk(); + expectCount(2500); + run(); + server.verify(); + } + + @Test + void pendingFileThatKeepsFailingIsSetAsideAndTopUpsContinue() throws Exception { + Files.writeString(pending(), "[{\"benRegId\":12"); // half-written by a power cut + for (int i = 0; i < 3; i++) { + server.expect(requestTo(IMPORT)).andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)); + } + expectCount(2500); + run(); + run(); + assertTrue(Files.exists(pending()), "kept while retries are left"); + run(); + server.verify(); + assertFalse(Files.exists(pending()), "set aside after 3 failures"); + try (java.util.stream.Stream files = Files.list(dataDir)) { + assertTrue(files.anyMatch(f -> f.getFileName().toString().startsWith("benid-pending-import.failed-"))); + } + } + + @Test + void retryWaitIsSpreadPlusMinus20Percent() { + ReflectionTestUtils.setField(service, "retryDelaysMs", new long[] { 10000L }); + for (int i = 0; i < 20; i++) { + long before = System.currentTimeMillis(); + ReflectionTestUtils.invokeMethod(service, "onFailure", new RuntimeException("down")); + long wait = (long) ReflectionTestUtils.getField(service, "nextAttemptAt") - before; + assertTrue(wait >= 7990 && wait <= 12010, "wait " + wait + " ms is outside 8-12 s"); + } + } +} diff --git a/src/test/java/com/iemr/mmu/service/benid/CentralAuthServiceTest.java b/src/test/java/com/iemr/mmu/service/benid/CentralAuthServiceTest.java new file mode 100644 index 00000000..d6ba45a0 --- /dev/null +++ b/src/test/java/com/iemr/mmu/service/benid/CentralAuthServiceTest.java @@ -0,0 +1,276 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.service.benid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.ExpectedCount; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +class CentralAuthServiceTest { + + private static final String LOGIN = "http://central/common-api/user/userAuthenticate"; + private static final String REFRESH = "http://central/common-api/user/refreshToken"; + private static final String SESSION = "http://central/common-api/user/getLoginResponse"; + + @TempDir + Path tokenDir; + + private CentralAuthService service; + private MockRestServiceServer central; + + @BeforeEach + void setUp() { + service = newService(true, 403); + } + + private CentralAuthService newService(boolean enforce, int vanID) { + CentralAuthService s = new CentralAuthService(); + ReflectionTestUtils.setField(s, "enforceVanID", enforce); + ReflectionTestUtils.setField(s, "vanID", vanID); + ReflectionTestUtils.setField(s, "centralLoginUrl", LOGIN); + ReflectionTestUtils.setField(s, "centralRefreshTokenUrl", REFRESH); + ReflectionTestUtils.setField(s, "centralSessionUrl", SESSION); + ReflectionTestUtils.setField(s, "tokenDir", tokenDir.toString()); + ReflectionTestUtils.setField(s, "tokenSecret", "test-secret"); + ReflectionTestUtils.setField(s, "connectTimeoutMs", 1000); + ReflectionTestUtils.setField(s, "readTimeoutMs", 1000); + s.init(); + RestTemplate rt = (RestTemplate) ReflectionTestUtils.getField(s, "restTemplate"); + central = MockRestServiceServer.bindTo(rt).build(); + return s; + } + + private void expectLoginOk(String refreshToken) { + central.expect(requestTo(LOGIN)).andExpect(method(HttpMethod.POST)) + .andExpect(header("User-Agent", "okhttp/4.12.0")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"doLogout\":false"))) + .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"refreshToken\":\"" + refreshToken + + "\",\"key\":\"k\"}}", MediaType.APPLICATION_JSON)); + } + + private void expectLoginRejected() { + central.expect(requestTo(LOGIN)).andRespond(withSuccess( + "{\"statusCode\":5002,\"errorMessage\":\"Invalid username or password\"}", MediaType.APPLICATION_JSON)); + } + + @Test + void disabledWithoutVanId() { + CentralAuthService off = newService(true, 0); + assertFalse(off.isEnabled()); + assertEquals(CentralAuthService.State.DISABLED, off.getState()); + assertFalse(off.capture("stoptb1", "pw", true)); + central.verify(); // nothing sent to central + } + + @Test + void disabledWhenEnforceFlagOff() { + CentralAuthService off = newService(false, 403); + assertFalse(off.isEnabled()); + assertFalse(off.capture("stoptb1", "pw", true)); + central.verify(); + } + + @Test + void captureStoresOnlyEncryptedTokenWithOwnerOnlyPermissions() throws Exception { + expectLoginOk("RT-secret-123"); + + assertTrue(service.capture("stoptb1", "encrypted-pw", false)); + + Path file = tokenDir.resolve("central-refresh-token"); + String stored = Files.readString(file, StandardCharsets.UTF_8); + assertFalse(stored.contains("RT-secret-123"), "token must not be stored in plain text"); + assertFalse(stored.contains("encrypted-pw"), "password must never be stored"); + assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file))); + assertEquals(CentralAuthService.State.CONNECTED, service.getState()); + central.verify(); + } + + @Test + void tokenIsReadBackAfterRestart() { + expectLoginOk("RT-1"); + service.capture("stoptb1", "pw", false); + + CentralAuthService restarted = newService(true, 403); + assertTrue(restarted.hasToken()); + assertEquals(CentralAuthService.State.CONNECTED, restarted.getState()); + } + + @Test + void corruptTokenFileMeansNewLoginNeeded() throws Exception { + Files.writeString(tokenDir.resolve("central-refresh-token"), "not-a-valid-token"); + CentralAuthService s = newService(true, 403); + assertFalse(s.hasToken()); + assertEquals(CentralAuthService.State.NOT_CONNECTED, s.getState()); + } + + @Test + void secondLoginIsSkippedWhileTokenWorks() { + expectLoginOk("RT-1"); + service.capture("stoptb1", "pw", false); + + assertFalse(service.capture("stoptb1", "pw", false)); + central.verify(); // only one central login + } + + @Test + void rejectedPasswordIsNeverSentAgainWithoutReconnect() { + expectLoginRejected(); + + assertFalse(service.capture("stoptb1", "wrong", false)); + assertEquals(CentralAuthService.State.PASSWORD_MISMATCH, service.getState()); + // next laptop login with the same user must not reach central (account lock risk) + assertFalse(service.capture("stoptb1", "wrong", false)); + central.verify(); + } + + @Test + void dataSyncReconnectRetriesAfterRejection() { + expectLoginRejected(); + expectLoginOk("RT-2"); + + service.capture("stoptb1", "wrong", false); + assertTrue(service.capture("stoptb1", "central-pw", true)); + assertEquals(CentralAuthService.State.CONNECTED, service.getState()); + central.verify(); + } + + @Test + void centralDownIsNotTreatedAsWrongPassword() { + central.expect(requestTo(LOGIN)).andRespond(withStatus(HttpStatus.BAD_GATEWAY)); + expectLoginOk("RT-3"); + + assertFalse(service.capture("stoptb1", "pw", false)); + // network problem: the next login tries again + assertTrue(service.capture("stoptb1", "pw", false)); + central.verify(); + } + + @Test + void sessionKeyFromRefreshTokenAndTokenRotation() throws Exception { + expectLoginOk("RT-old"); + central.expect(requestTo(REFRESH)) + .andExpect(content().string(org.hamcrest.Matchers.containsString("RT-old"))) + .andRespond(withSuccess("{\"jwtToken\":\"JWT-1\",\"refreshToken\":\"RT-new\"}", + MediaType.APPLICATION_JSON)); + central.expect(requestTo(SESSION)).andExpect(header("Jwttoken", "JWT-1")) + .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"key\":\"SESSION-KEY\"}}", + MediaType.APPLICATION_JSON)); + // the next refresh must use the rotated token + central.expect(requestTo(REFRESH)) + .andExpect(content().string(org.hamcrest.Matchers.containsString("RT-new"))) + .andRespond(withSuccess("{\"jwtToken\":\"JWT-2\"}", MediaType.APPLICATION_JSON)); + central.expect(requestTo(SESSION)).andRespond(withSuccess( + "{\"statusCode\":200,\"data\":\"{\\\"key\\\":\\\"KEY-2\\\"}\"}", MediaType.APPLICATION_JSON)); + + service.capture("stoptb1", "pw", false); + String fileBefore = Files.readString(tokenDir.resolve("central-refresh-token")); + + assertEquals("SESSION-KEY", service.getCentralSessionKey()); + assertFalse(fileBefore.equals(Files.readString(tokenDir.resolve("central-refresh-token"))), + "rotated refresh token must be saved"); + assertEquals("KEY-2", service.getCentralSessionKey()); + central.verify(); + } + + @Test + void revokedTokenIsDeletedAndReconnectNeeded() { + expectLoginOk("RT-1"); + central.expect(requestTo(REFRESH)).andRespond(withStatus(HttpStatus.UNAUTHORIZED)); + + service.capture("stoptb1", "pw", false); + + assertNull(assertDoesNotThrowValue()); + assertFalse(service.hasToken()); + assertFalse(Files.exists(tokenDir.resolve("central-refresh-token"))); + assertEquals(CentralAuthService.State.RECONNECT_NEEDED, service.getState()); + central.verify(); + } + + @Test + void noTokenMeansNoCentralCall() throws Exception { + central.expect(ExpectedCount.never(), requestTo(REFRESH)); + assertNull(service.getCentralSessionKey()); + central.verify(); + } + + private String assertDoesNotThrowValue() { + try { + return service.getCentralSessionKey(); + } catch (Exception e) { + throw new AssertionError("revoked token must not throw", e); + } + } + + @Test + void rejectedPasswordIsRememberedAfterRestart() { + expectLoginRejected(); + service.capture("stoptb1", "wrong", false); + central.verify(); + + CentralAuthService restarted = newService(true, 403); + // laptop login after the restart must not reach central with the rejected password + assertFalse(restarted.capture("stoptb1", "wrong", false)); + central.verify(); + } + + @Test + void successfulReconnectClearsTheRejectedMarker() { + expectLoginRejected(); + expectLoginOk("RT-9"); + service.capture("stoptb1", "wrong", false); + assertTrue(service.capture("stoptb1", "central-pw", true)); + assertFalse(Files.exists(tokenDir.resolve("central-login-rejected"))); + central.verify(); + } + + @Test + void rejectedMarkerHoldsNoPassword() throws Exception { + expectLoginRejected(); + service.capture("stoptb1", "secret-pw", false); + String marker = Files.readString(tokenDir.resolve("central-login-rejected")); + assertEquals("stoptb1", marker.trim()); + assertFalse(marker.contains("secret-pw")); + } +} diff --git a/src/test/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncServiceTest.java b/src/test/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncServiceTest.java new file mode 100644 index 00000000..07fa285f --- /dev/null +++ b/src/test/java/com/iemr/mmu/service/dataSyncActivity/AutoSyncServiceTest.java @@ -0,0 +1,176 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.service.dataSyncActivity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import com.iemr.mmu.service.benid.BenIdAutoTopUpService; +import com.iemr.mmu.service.benid.CentralAuthService; + +class AutoSyncServiceTest { + + private AutoSyncService service; + private CentralAuthService centralAuth; + private UploadDataToServerImpl upload; + private DownloadDataFromServerImpl download; + private DataSyncRepository repo; + private BenIdAutoTopUpService benId; + private DownloadDataFromServerTransactionalImpl inventory; + + @BeforeEach + void setUp() throws Exception { + centralAuth = mock(CentralAuthService.class); + upload = mock(UploadDataToServerImpl.class); + download = mock(DownloadDataFromServerImpl.class); + repo = mock(DataSyncRepository.class); + benId = mock(BenIdAutoTopUpService.class); + inventory = mock(DownloadDataFromServerTransactionalImpl.class); + when(centralAuth.isEnabled()).thenReturn(true); + when(centralAuth.getVanID()).thenReturn(403); + when(centralAuth.getConnectedUser()).thenReturn("stoptb1"); + when(centralAuth.getCentralSessionKey()).thenReturn("SESSION-KEY"); + when(repo.getVanProviderServiceMapID(403)).thenReturn(1726); + + service = new AutoSyncService(); + ReflectionTestUtils.setField(service, "centralAuthService", centralAuth); + ReflectionTestUtils.setField(service, "uploadDataToServerImpl", upload); + ReflectionTestUtils.setField(service, "downloadDataFromServerImpl", download); + ReflectionTestUtils.setField(service, "dataSyncRepository", repo); + ReflectionTestUtils.setField(service, "benIdAutoTopUpService", benId); + ReflectionTestUtils.setField(service, "downloadDataFromServerTransactional", inventory); + ReflectionTestUtils.setField(service, "inventoryDownloadEnabled", true); + ReflectionTestUtils.setField(service, "uploadEnabled", true); + ReflectionTestUtils.setField(service, "masterDownloadEnabled", true); + ReflectionTestUtils.setField(service, "masterDownloadInventoryOnly", true); + } + + @Test + void uploadUsesCentralKeyAndConnectedOperator() throws Exception { + when(upload.getDataToSyncToServer(403, "stoptb1", "SESSION-KEY", null)).thenReturn("Data successfully synced"); + + service.uploadOnce(); + + verify(upload).getDataToSyncToServer(403, "stoptb1", "SESSION-KEY", null); + verify(benId).triggerNow(); + assertEquals("Data successfully synced", service.status().get("lastUploadResult")); + } + + @Test + void uploadIsFollowedByInventoryDownload() throws Exception { + when(upload.getDataToSyncToServer(403, "stoptb1", "SESSION-KEY", null)).thenReturn("Data successfully synced"); + when(inventory.downloadTransactionalData(403, "SESSION-KEY", "datasync")).thenReturn(1); + + service.uploadOnce(); + + verify(inventory).downloadTransactionalData(403, "SESSION-KEY", "datasync"); + assertEquals("success", service.status().get("lastInventoryDownloadResult")); + } + + @Test + void inventoryDownloadFailureKeepsUploadResult() throws Exception { + when(upload.getDataToSyncToServer(403, "stoptb1", "SESSION-KEY", null)).thenReturn("Data successfully synced"); + when(inventory.downloadTransactionalData(403, "SESSION-KEY", "datasync")).thenThrow(new RuntimeException("offline")); + + service.uploadOnce(); + + assertEquals("Data successfully synced", service.status().get("lastUploadResult")); + assertEquals("Inventory download: offline", service.status().get("lastError")); + } + + @Test + void noCentralConnectionSkipsUpload() throws Exception { + when(centralAuth.getCentralSessionKey()).thenReturn(null); + + service.uploadOnce(); + + verify(upload, never()).getDataToSyncToServer(anyInt(), anyString(), anyString(), any()); + assertEquals("No central connection - waiting for a user login", service.status().get("lastError")); + } + + @Test + void masterDownloadUsesVanServiceLineAndOnlyInventoryMasters() throws Exception { + when(download.downloadMasterDataFromServer("SESSION-KEY", null, 403, 1726, AutoSyncService.INVENTORY_MASTERS)) + .thenReturn("success"); + + service.masterDownloadOnce(); + + verify(download).downloadMasterDataFromServer("SESSION-KEY", null, 403, 1726, AutoSyncService.INVENTORY_MASTERS); + assertEquals("success", service.status().get("lastMasterDownloadResult")); + } + + @Test + void masterDownloadCanFetchAllMasters() throws Exception { + ReflectionTestUtils.setField(service, "masterDownloadInventoryOnly", false); + when(download.downloadMasterDataFromServer("SESSION-KEY", null, 403, 1726, null)).thenReturn("success"); + + service.masterDownloadOnce(); + + verify(download).downloadMasterDataFromServer("SESSION-KEY", null, 403, 1726, null); + } + + @Test + void manualMasterDownloadRunningIsNotCountedAsARun() throws Exception { + when(download.downloadMasterDataFromServer("SESSION-KEY", null, 403, 1726, AutoSyncService.INVENTORY_MASTERS)) + .thenReturn("inProgress"); + + service.masterDownloadOnce(); + + assertEquals(0L, service.status().getLong("lastMasterDownloadAt")); + } + + @Test + void statusSumsRejectedRows() throws Exception { + when(repo.getSyncFailures()).thenReturn(List.of( + Map.of("tableName", "t_patientissue", "reason", "Duplicate", "failedRows", 2L), + Map.of("tableName", "t_prescription", "reason", "Timeout", "failedRows", 3L))); + + JSONObject status = service.status(); + + assertEquals(5L, status.getLong("failedRows")); + assertEquals(2, status.getJSONArray("failures").length()); + } + + @Test + void disabledLaptopDoesNothing() throws Exception { + when(centralAuth.isEnabled()).thenReturn(false); + + service.scheduledUpload(); + service.scheduledMasterDownload(); + + verify(centralAuth, never()).getCentralSessionKey(); + } +} diff --git a/src/test/java/com/iemr/mmu/service/dataSyncActivity/DownloadMastersFullForLineTest.java b/src/test/java/com/iemr/mmu/service/dataSyncActivity/DownloadMastersFullForLineTest.java new file mode 100644 index 00000000..d92182b2 --- /dev/null +++ b/src/test/java/com/iemr/mmu/service/dataSyncActivity/DownloadMastersFullForLineTest.java @@ -0,0 +1,49 @@ +package com.iemr.mmu.service.dataSyncActivity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.sql.Timestamp; + +import org.junit.jupiter.api.Test; + +import com.iemr.mmu.data.syncActivity_syncLayer.SyncDownloadMaster; + +class DownloadMastersFullForLineTest { + + private static final Timestamp COPIED_DATE = Timestamp.valueOf("2026-10-16 13:17:31"); + + private static SyncDownloadMaster table(String masterType) { + SyncDownloadMaster t = new SyncDownloadMaster(); + t.setMasterType(masterType); + t.setLastDownloadDate(COPIED_DATE); + return t; + } + + @Test + void serviceLineMastersAreDownloadedInFull() { + SyncDownloadMaster parkingPlace = table("P"); + + DownloadDataFromServerImpl.fullDownloadForLineAndVanMasters(parkingPlace); + + assertNull(parkingPlace.getLastDownloadDate()); + } + + @Test + void vanMastersAreDownloadedInFull() { + SyncDownloadMaster vanServicePoint = table("v"); + + DownloadDataFromServerImpl.fullDownloadForLineAndVanMasters(vanServicePoint); + + assertNull(vanServicePoint.getLastDownloadDate()); + } + + @Test + void mastersSharedByAllLinesStayIncremental() { + SyncDownloadMaster villages = table("A"); + + DownloadDataFromServerImpl.fullDownloadForLineAndVanMasters(villages); + + assertEquals(COPIED_DATE, villages.getLastDownloadDate()); + } +} diff --git a/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadRenamedColumnTest.java b/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadRenamedColumnTest.java new file mode 100644 index 00000000..adbc39de --- /dev/null +++ b/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadRenamedColumnTest.java @@ -0,0 +1,48 @@ +package com.iemr.mmu.service.dataSyncActivity; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +class UploadRenamedColumnTest { + + private static final String VAN = "status,error_message,reason_for_refusal,push_response_json," + + "date_format(last_mod_date,'%Y-%m-%d %H:%i:%s'),vanSerialNo"; + private static final String SERVER = "status,error_message,reason_for_refusal,push_response_json,last_mod_date,vanSerialNo"; + + @Test + void oldNameInSyncListUsesTheTableName() { + Set table = Set.of("status", "error_message", "reason_to_close", "push_response_json", "last_mod_date", + "vanserialno"); + + String[] cols = UploadDataToServerImpl.useRenamedColumns("tb_diagnostic_order", VAN, SERVER, table); + + assertArrayEquals(new String[] { VAN.replace("reason_for_refusal", "reason_to_close"), + SERVER.replace("reason_for_refusal", "reason_to_close") }, cols); + } + + @Test + void tableStillWithOldNameIsUnchanged() { + Set table = Set.of("status", "error_message", "reason_for_refusal", "push_response_json"); + + assertArrayEquals(new String[] { VAN, SERVER }, + UploadDataToServerImpl.useRenamedColumns("tb_diagnostic_order", VAN, SERVER, table)); + } + + @Test + void newNameInSyncListOnOlderTable() { + String van = "status,reason_to_close", server = "status,reason_to_close"; + + assertArrayEquals(new String[] { "status,reason_for_refusal", "status,reason_for_refusal" }, + UploadDataToServerImpl.useRenamedColumns("tb_diagnostic_order", van, server, + Set.of("status", "reason_for_refusal"))); + } + + @Test + void otherTablesAreUnchanged() { + assertArrayEquals(new String[] { VAN, SERVER }, UploadDataToServerImpl.useRenamedColumns("t_tbsuspected", VAN, + SERVER, Set.of("reason_to_close"))); + } +} diff --git a/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadSkipDuplicateGroupTest.java b/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadSkipDuplicateGroupTest.java new file mode 100644 index 00000000..438922a5 --- /dev/null +++ b/src/test/java/com/iemr/mmu/service/dataSyncActivity/UploadSkipDuplicateGroupTest.java @@ -0,0 +1,51 @@ +package com.iemr.mmu.service.dataSyncActivity; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import com.iemr.mmu.data.syncActivity_syncLayer.SyncUtilityClass; + +class UploadSkipDuplicateGroupTest { + + private static SyncUtilityClass table(int group, String name) { + SyncUtilityClass t = new SyncUtilityClass(); + t.setSyncTableGroupID(group); + t.setTableName(name); + return t; + } + + private static List names(List tables) { + return tables.stream().map(SyncUtilityClass::getTableName).collect(Collectors.toList()); + } + + // as on the laptops: inventory tables in group 5 (old lists) and group 9 (complete) + private final List all = List.of(table(4, "t_Prescription"), table(5, "t_Lab_TestResult"), + table(5, "t_ItemStockEntry"), table(5, "t_ItemStockExit"), table(5, "t_PatientIssue"), + table(9, "t_itemstockentry"), table(9, "t_itemstockexit"), table(9, "t_patientissue"), + table(9, "t_stockadjustment")); + + @Test + void inventoryTablesAreSkippedInGroup5() { + List group5 = all.stream().filter(t -> t.getSyncTableGroupID() == 5).collect(Collectors.toList()); + + assertEquals(List.of("t_Lab_TestResult"), names(UploadDataToServerImpl.skipTablesUploadedByLaterGroup(group5, 5, all))); + } + + @Test + void group9StillUploadsThem() { + List group9 = all.stream().filter(t -> t.getSyncTableGroupID() == 9).collect(Collectors.toList()); + + assertEquals(names(group9), names(UploadDataToServerImpl.skipTablesUploadedByLaterGroup(group9, 9, all))); + } + + @Test + void tablesInOneGroupAreUnchanged() { + List group4 = List.of(table(4, "t_Prescription")); + + assertEquals(List.of("t_Prescription"), names(UploadDataToServerImpl.skipTablesUploadedByLaterGroup(group4, 4, all))); + } +} diff --git a/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanKeysTest.java b/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanKeysTest.java new file mode 100644 index 00000000..23a2a513 --- /dev/null +++ b/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanKeysTest.java @@ -0,0 +1,71 @@ +package com.iemr.mmu.service.dataSyncLayerCentral; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class GetDataFromVanAndSyncToDBVanKeysTest { + + // server list of t_itemstockentry (van list has ItemStockEntryID in VanSerialNo's place) + private static final List STOCK_ENTRY_SERVER = Arrays.asList("FacilityID", "ItemID", "BatchNo", + "QuantityInHand", "VanSerialNo", "VanID", "SyncFacilityID"); + + @Test + void vanSerialNoTakesTheVanRowIdWhenNotSentByName() { + Map record = new HashMap<>(Map.of("ItemStockEntryID", 6, "FacilityID", 428, "VanID", 428)); + + GetDataFromVanAndSyncToDBImpl.fillVanKeys(record, STOCK_ENTRY_SERVER, "ItemStockEntryID"); + + assertEquals(6, record.get("VanSerialNo")); + } + + @Test + void syncFacilityIdTakesTheRowStoreWhenNotSent() { + Map record = new HashMap<>(Map.of("ItemStockExitID", 9, "FacilityID", 428)); + + GetDataFromVanAndSyncToDBImpl.fillVanKeys(record, Arrays.asList("ItemStockEntryID", "FacilityID", + " VanSerialNo", "SyncFacilityID"), "ItemStockExitID"); + + assertEquals(428, record.get("SyncFacilityID")); + assertEquals(9, record.get("VanSerialNo")); + } + + @Test + void valuesSentByTheVanAreKept() { + Map record = new HashMap<>( + Map.of("PatientIssueID", 3, "VanSerialNo", 3, "FacilityID", 428, "SyncFacilityID", 428)); + + GetDataFromVanAndSyncToDBImpl.fillVanKeys(record, Arrays.asList("VanSerialNo", "SyncFacilityID"), + "PatientIssueID"); + + assertEquals(3, record.get("VanSerialNo")); + assertEquals(428, record.get("SyncFacilityID")); + } + + @Test + void columnsNotOnTheServerAreNotAdded() { + Map record = new HashMap<>(Map.of("BenVisitID", 1991, "VanID", 428)); + + GetDataFromVanAndSyncToDBImpl.fillVanKeys(record, Arrays.asList("BeneficiaryRegID", "VanSerialNo", "VanID"), + "BenVisitID"); + + assertEquals(1991, record.get("VanSerialNo")); + assertFalse(record.containsKey("SyncFacilityID")); + } + + @Test + void missingVanIdLeavesVanSerialNoEmpty() { + Map record = new HashMap<>(Map.of("FacilityID", 428)); + + GetDataFromVanAndSyncToDBImpl.fillVanKeys(record, STOCK_ENTRY_SERVER, "ItemStockEntryID"); + + assertNull(record.get("VanSerialNo")); + } +} diff --git a/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanLinksTest.java b/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanLinksTest.java new file mode 100644 index 00000000..0b5e6017 --- /dev/null +++ b/src/test/java/com/iemr/mmu/service/dataSyncLayerCentral/GetDataFromVanAndSyncToDBVanLinksTest.java @@ -0,0 +1,101 @@ +package com.iemr.mmu.service.dataSyncLayerCentral; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class GetDataFromVanAndSyncToDBVanLinksTest { + + private GetDataFromVanAndSyncToDBImpl service; + private DataSyncRepositoryCentral repo; + + @BeforeEach + void setUp() { + service = new GetDataFromVanAndSyncToDBImpl(); + repo = mock(DataSyncRepositoryCentral.class); + ReflectionTestUtils.setField(service, "dataSyncRepositoryCentral", repo); + } + + private static Map row(Object... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) + m.put((String) kv[i], kv[i + 1]); + return m; + } + + @Test + void prescriptionGetsTheCentralIdOfItsVisit() { + // laptop visit 1261 of van 428 is central visit 5003 (central's own 1261 is another van's visit) + when(repo.findCentralIdOfVanRow("db_iemr", "t_benvisitdetail", "BenVisitID", 1261, "428")).thenReturn(5003L); + Map rx = row("PrescriptionID", 378, "BenVisitID", 1261, "VanID", 428); + + String error = service.translateVanLinks("db_iemr", "t_prescription", rx, + List.of("BenVisitID", "VanSerialNo", "VanID"), "428"); + + assertNull(error); + assertEquals(5003L, rx.get("BenVisitID")); + assertEquals(378, rx.get("PrescriptionID")); // own ID of t_prescription is not a link + } + + @Test + void prescribedDrugGetsCentralVisitAndPrescription() { + when(repo.findCentralIdOfVanRow("db_iemr", "t_benvisitdetail", "BenVisitID", 1261, "428")).thenReturn(5003L); + when(repo.findCentralIdOfVanRow("db_iemr", "t_prescription", "PrescriptionID", 378, "428")).thenReturn(9001L); + Map drug = row("PrescribedDrugID", 5, "BenVisitID", 1261, "PrescriptionID", 378); + + assertNull(service.translateVanLinks("db_iemr", "t_prescribeddrug", drug, + Arrays.asList("BenVisitID", "PrescriptionID", "VanSerialNo"), "428")); + + assertEquals(5003L, drug.get("BenVisitID")); + assertEquals(9001L, drug.get("PrescriptionID")); + } + + @Test + void parentNotOnCentralYetIsNotSaved() { + when(repo.findCentralIdOfVanRow(anyString(), anyString(), anyString(), any(), anyString())).thenReturn(null); + Map rx = row("BenVisitID", 1991); + + String error = service.translateVanLinks("db_iemr", "t_prescription", rx, List.of("BenVisitID"), "428"); + + assertNotNull(error); + assertEquals(1991, rx.get("BenVisitID")); // left as it was, row not saved + } + + @Test + void visitItselfAndRowsWithoutLinksAreUnchanged() { + Map visit = row("BenVisitID", 1261, "VisitCode", 10042800001261L); + assertNull(service.translateVanLinks("db_iemr", "t_benvisitdetail", visit, List.of("BenVisitID"), "428")); + assertEquals(1261, visit.get("BenVisitID")); + + Map batch = row("ItemStockEntryID", 41, "FacilityID", 428); + assertNull(service.translateVanLinks("db_iemr", "t_itemstockentry", batch, List.of("FacilityID"), "428")); + + Map noVan = row("BenVisitID", 1261); + assertNull(service.translateVanLinks("db_iemr", "t_prescription", noVan, List.of("BenVisitID"), null)); + verify(repo, never()).findCentralIdOfVanRow(anyString(), anyString(), anyString(), any(), anyString()); + } + + @Test + void linkColumnNotUploadedIsLeftAlone() { + Map issue = row("PrescriptionID", 378); + + assertNull(service.translateVanLinks("db_iemr", "t_patientissue", issue, List.of("VisitCode"), "428")); + + verify(repo, never()).findCentralIdOfVanRow(anyString(), anyString(), anyString(), any(), anyString()); + } +}