diff --git a/pom.xml b/pom.xml
index dc78ec59..5749c203 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
4.0.0com.iemr.mmummu-api
- 3.9.2
+ 3.12.0warMMU-API
diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties
index 34875d64..cd33f081 100644
--- a/src/main/environment/common_ci.properties
+++ b/src/main/environment/common_ci.properties
@@ -106,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 be5f3608..38be2dce 100644
--- a/src/main/environment/common_docker.properties
+++ b/src/main/environment/common_docker.properties
@@ -104,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 515bb23c..63dd2fa8 100644
--- a/src/main/environment/common_example.properties
+++ b/src/main/environment/common_example.properties
@@ -120,4 +120,18 @@ 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
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/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 3d1f211e..76a9477b 100644
--- a/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java
+++ b/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java
@@ -37,6 +37,7 @@
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;
@@ -71,6 +72,9 @@ public class StartSyncActivity {
@Autowired
private DownSyncDataFromServerImpl downSyncDataFromServerImpl;
+ @Autowired
+ private BenIdAutoTopUpService benIdAutoTopUpService;
+
private static final String GROUP_ID = "groupID";
private static final String PROVIDER_SERVICE_MAP_ID = "providerServiceMapID";
@@ -89,6 +93,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 {
@@ -180,8 +186,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 {
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