diff --git a/.gitignore b/.gitignore
index c9a2255a..5db74692 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,4 +43,8 @@ src/main/environment/admin_local.properties
node_modules
# Logs
-logs/
\ No newline at end of file
+logs/
+*.api-results.csv
+test-results/
+playwright-report/
+playwright/.cache/
diff --git a/pom.xml b/pom.xml
index 7f8b0a6d..5be9a89c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
4.0.0
com.iemr.admin
admin-api
- 3.12.0
+ 3.13.0
war
Admin-API
Admin Page
diff --git a/src/main/environment/admin_ci.properties b/src/main/environment/admin_ci.properties
index 92823806..70ed8124 100644
--- a/src/main/environment/admin_ci.properties
+++ b/src/main/environment/admin_ci.properties
@@ -26,3 +26,9 @@ springdoc.api-docs.enabled=@env.SWAGGER_DOC_ENABLED@
springdoc.swagger-ui.enabled=@env.SWAGGER_DOC_ENABLED@
cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@
+
+# Ni-kshay device PHI password encryption key (Base64, 32 bytes); MMU-API must use the same key
+nikshay.device.aes-key=@env.NIKSHAY_DEVICE_AES_KEY@
+nikshay.portal.env=@env.NIKSHAY_PORTAL_ENV@
+nikshay.job.cron=${NIKSHAY_JOB_CRON}
+nikshay.phase.batch-size=${NIKSHAY_PHASE_BATCH_SIZE:50}
\ No newline at end of file
diff --git a/src/main/environment/admin_docker.properties b/src/main/environment/admin_docker.properties
index d530138a..e41b112d 100644
--- a/src/main/environment/admin_docker.properties
+++ b/src/main/environment/admin_docker.properties
@@ -1,24 +1,30 @@
-# local env
-# DB Connections
-spring.datasource.url=${DATABASE_URL}
-spring.datasource.username=${DATABASE_USERNAME}
-spring.datasource.password=${DATABASE_PASSWORD}
-spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
-
-callcentre-server-ip=${CALLCENTRE_SERVER_IP}
-
-videoConsultation-apikey=${SWYMED_APIKEY}
-videoConsultation-base-url=${SWYMED_BASE_URL}
-
-### Redis IP
-spring.redis.host=${REDIS_HOST}
-spring.main.allow-bean-definition-overriding=true
-jwt.secret=${JWT_SECRET_KEY}
-#ELK logging file name
-logging.path=logs/
-logging.file.name=${ADMIN_API_LOGGING_FILE_NAME}
-
-common-url=${COMMON_API}
-
-springdoc.api-docs.enabled=${SWAGGER_DOC_ENABLED}
-springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED}
+# local env
+# DB Connections
+spring.datasource.url=${DATABASE_URL}
+spring.datasource.username=${DATABASE_USERNAME}
+spring.datasource.password=${DATABASE_PASSWORD}
+spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
+
+callcentre-server-ip=${CALLCENTRE_SERVER_IP}
+
+videoConsultation-apikey=${SWYMED_APIKEY}
+videoConsultation-base-url=${SWYMED_BASE_URL}
+
+### Redis IP
+spring.redis.host=${REDIS_HOST}
+spring.main.allow-bean-definition-overriding=true
+jwt.secret=${JWT_SECRET_KEY}
+#ELK logging file name
+logging.path=logs/
+logging.file.name=${ADMIN_API_LOGGING_FILE_NAME}
+
+common-url=${COMMON_API}
+
+springdoc.api-docs.enabled=${SWAGGER_DOC_ENABLED}
+springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED}
+
+# Ni-kshay device PHI password encryption key (Base64, 32 bytes); MMU-API must use the same key
+nikshay.device.aes-key=${NIKSHAY_DEVICE_AES_KEY:}
+nikshay.portal.env=${NIKSHAY_PORTAL_ENV:uat}
+nikshay.job.cron=${NIKSHAY_JOB_CRON}
+nikshay.phase.batch-size=${NIKSHAY_PHASE_BATCH_SIZE:50}
\ No newline at end of file
diff --git a/src/main/environment/admin_example.properties b/src/main/environment/admin_example.properties
index f510b42b..b4e1ae54 100644
--- a/src/main/environment/admin_example.properties
+++ b/src/main/environment/admin_example.properties
@@ -26,3 +26,8 @@ logging.file.name=logs/admin-api.log
jwt.secret=my-32-character-ultra-secure-and-ultra-long-secret
cors.allowed-origins=http://localhost:*
+
+nikshay.device.aes-key=${NIKSHAY_DEVICE_AES_KEY:}
+nikshay.portal.env=uat
+nikshay.job.cron=${NIKSHAY_JOB_CRON}
+nikshay.phase.batch-size=${NIKSHAY_PHASE_BATCH_SIZE:50}
diff --git a/src/main/java/com/iemr/admin/config/NikshayJobSchedulingConfig.java b/src/main/java/com/iemr/admin/config/NikshayJobSchedulingConfig.java
new file mode 100644
index 00000000..05275f31
--- /dev/null
+++ b/src/main/java/com/iemr/admin/config/NikshayJobSchedulingConfig.java
@@ -0,0 +1,78 @@
+/*
+* AMRIT – Accessible Medical Records via Integrated Technologies
+* 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.admin.config;
+
+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.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.SchedulingConfigurer;
+import org.springframework.scheduling.config.ScheduledTaskRegistrar;
+import org.springframework.scheduling.support.CronExpression;
+
+import com.iemr.admin.service.nikshayphasestatus.NikshayPhaseJob;
+
+/**
+ * Schedules the one Ni-kshay job (NikshayPhaseJob) with the cron expression in
+ * nikshay.job.cron (env NIKSHAY_JOB_CRON; Spring format: second minute hour day month
+ * weekday, e.g. 0 0 2 * * * for 02:00 every day). The application does not start when the
+ * value is missing, blank or not a valid cron expression. "-" turns the job off. A change
+ * needs a restart. Runs on one thread, so a run never overlaps the previous one.
+ */
+@Configuration
+@EnableScheduling
+public class NikshayJobSchedulingConfig implements SchedulingConfigurer {
+ private static final Logger logger = LoggerFactory.getLogger(NikshayJobSchedulingConfig.class);
+
+ static final String CRON_PROPERTY = "nikshay.job.cron";
+
+ // Spring's value for "no schedule"
+ private static final String CRON_DISABLED = "-";
+
+ // No default: a missing NIKSHAY_JOB_CRON stops the application at startup
+ @Value("${" + CRON_PROPERTY + "}")
+ private String cron;
+
+ @Autowired
+ private NikshayPhaseJob nikshayPhaseJob;
+
+ @Override
+ public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
+ String expression = cron == null ? "" : cron.trim();
+ if (expression.isEmpty()) {
+ throw new IllegalStateException(CRON_PROPERTY + " (env NIKSHAY_JOB_CRON) is not set. Set a cron expression, "
+ + "e.g. 0 0 2 * * *, or - to turn the Ni-kshay job off");
+ }
+ if (CRON_DISABLED.equals(expression)) {
+ logger.info("{} is '-': the automatic Ni-kshay job is off", CRON_PROPERTY);
+ return;
+ }
+ if (!CronExpression.isValidExpression(expression)) {
+ throw new IllegalStateException(CRON_PROPERTY + " (env NIKSHAY_JOB_CRON) '" + expression
+ + "' is not a valid cron expression (second minute hour day month weekday)");
+ }
+ taskRegistrar.addCronTask(nikshayPhaseJob::runAutomaticPhases, expression);
+ logger.info("Automatic Ni-kshay job scheduled with {} '{}'", CRON_PROPERTY, expression);
+ }
+}
diff --git a/src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java
new file mode 100644
index 00000000..7c3e78ce
--- /dev/null
+++ b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java
@@ -0,0 +1,121 @@
+package com.iemr.admin.controller.nikshay;
+
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.iemr.admin.model.nikshay.NikshayActivateRequest;
+import com.iemr.admin.model.nikshay.NikshayDesignatedPhiRequest;
+import com.iemr.admin.service.nikshay.NikshayDesignatedPhiService;
+import com.iemr.admin.utils.response.OutputResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+
+/**
+ * Designated PHI of each Ni-kshay village, used by the transfer out phase of
+ * the Ni-kshay job (Module 1 of the Ni-kshay integration). Responses keep null
+ * fields (e.g. designationHierarchyId) so every row has the same keys.
+ */
+@RestController
+@RequestMapping(value = "/nikshay/designatedPhi")
+public class NikshayDesignatedPhiController {
+ private final Logger logger = LoggerFactory.getLogger(this.getClass().getName());
+
+ private static final Gson GSON = new GsonBuilder().serializeNulls().create();
+
+ @Autowired
+ private NikshayDesignatedPhiService nikshayDesignatedPhiService;
+
+ @Operation(summary = "Save a designated PHI for a village (a village can have several); saving the same village, TBU and PHI again updates it")
+ @PostMapping(value = "/save", produces = "application/json", headers = "Authorization")
+ public String saveDesignatedPhi(@RequestBody NikshayDesignatedPhiRequest request) {
+ OutputResponse response = new OutputResponse();
+ try {
+ response.setResponse(GSON.toJson(nikshayDesignatedPhiService.saveDesignatedPhi(request)));
+ } catch (Exception e) {
+ logger.error("saveDesignatedPhi failed: " + e.getMessage(), e);
+ response.setError(e);
+ }
+ return response.toStringWithSerialization();
+ }
+
+ @Operation(summary = "Update a designated PHI mapping by id; only the fields sent (not null) are changed")
+ @PostMapping(value = "/update", produces = "application/json", headers = "Authorization")
+ public String updateDesignatedPhi(@RequestBody NikshayDesignatedPhiRequest request) {
+ OutputResponse response = new OutputResponse();
+ try {
+ response.setResponse(GSON.toJson(nikshayDesignatedPhiService.updateDesignatedPhi(request)));
+ } catch (Exception e) {
+ logger.error("updateDesignatedPhi failed: " + e.getMessage(), e);
+ response.setError(e);
+ }
+ return response.toStringWithSerialization();
+ }
+
+ @Operation(summary = "Activate / deactivate a designated PHI mapping")
+ @PostMapping(value = "/activate", produces = "application/json", headers = "Authorization")
+ public String setActive(@RequestBody NikshayActivateRequest request) {
+ OutputResponse response = new OutputResponse();
+ try {
+ response.setResponse(GSON.toJson(nikshayDesignatedPhiService.setActive(request)));
+ } catch (Exception e) {
+ logger.error("setActive designated PHI failed: " + e.getMessage(), e);
+ response.setError(e);
+ }
+ return response.toStringWithSerialization();
+ }
+
+ @Operation(summary = "Mark a designated PHI mapping inactive by id (body: id, updatedBy)")
+ @PostMapping(value = "/markInactive", produces = "application/json", headers = "Authorization")
+ public String markInactive(@RequestBody NikshayActivateRequest request) {
+ OutputResponse response = new OutputResponse();
+ try {
+ response.setResponse(GSON.toJson(nikshayDesignatedPhiService.markInactive(request)));
+ } catch (Exception e) {
+ logger.error("markInactive designated PHI failed: " + e.getMessage(), e);
+ response.setError(e);
+ }
+ return response.toStringWithSerialization();
+ }
+
+ @Operation(summary = "Get designated PHI by village, TBU or district. unmappedOnly=true lists villages "
+ + "without an active designated PHI; activeOnly=false includes inactive rows")
+ @GetMapping(value = "/get", produces = "application/json", headers = "Authorization")
+ public String getDesignatedPhi(@RequestParam(value = "villageId", required = false) Integer villageId,
+ @RequestParam(value = "tbuId", required = false) Integer tbuId,
+ @RequestParam(value = "districtId", required = false) Integer districtId,
+ @RequestParam(value = "activeOnly", required = false, defaultValue = "true") Boolean activeOnly,
+ @RequestParam(value = "unmappedOnly", required = false, defaultValue = "false") Boolean unmappedOnly) {
+ OutputResponse response = new OutputResponse();
+ try {
+ response.setResponse(GSON.toJson(
+ nikshayDesignatedPhiService.getDesignatedPhi(villageId, tbuId, districtId, activeOnly, unmappedOnly)));
+ } catch (Exception e) {
+ logger.error("getDesignatedPhi failed: " + e.getMessage(), e);
+ response.setError(e);
+ }
+ return response.toStringWithSerialization();
+ }
+
+ @Operation(summary = "Get all designated PHI mappings; activeOnly=true leaves out inactive rows")
+ @GetMapping(value = "/getAll", produces = "application/json", headers = "Authorization")
+ public String getAllDesignatedPhi(
+ @RequestParam(value = "activeOnly", required = false, defaultValue = "false") Boolean activeOnly) {
+ OutputResponse response = new OutputResponse();
+ try {
+ response.setResponse(GSON.toJson(nikshayDesignatedPhiService.getAllDesignatedPhi(activeOnly)));
+ } catch (Exception e) {
+ logger.error("getAllDesignatedPhi failed: " + e.getMessage(), e);
+ response.setError(e);
+ }
+ return response.toStringWithSerialization();
+ }
+}
diff --git a/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java
new file mode 100644
index 00000000..e2971221
--- /dev/null
+++ b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java
@@ -0,0 +1,162 @@
+package com.iemr.admin.controller.nikshay;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.iemr.admin.model.nikshay.NikshayActivateRequest;
+import com.iemr.admin.model.nikshay.NikshayDeviceBulkCreateRequest;
+import com.iemr.admin.model.nikshay.NikshayDeviceRequest;
+import com.iemr.admin.model.nikshay.NikshayVillageEnrollRequest;
+import com.iemr.admin.model.nikshay.NikshayVillageRemoveRequest;
+import com.iemr.admin.service.nikshay.NikshayDeviceService;
+import com.iemr.admin.utils.response.OutputResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+
+/**
+ * Ni-kshay camp device configuration (Module 1 of the Ni-kshay integration):
+ * devices with their PHI login, and the villages each device serves.
+ * Request bodies are not logged here because device requests carry passwords.
+ */
+@RestController
+@RequestMapping(value = "/nikshay/device")
+public class NikshayDeviceController {
+ private final Logger logger = LoggerFactory.getLogger(this.getClass().getName());
+
+ private static final Gson GSON = new GsonBuilder().serializeNulls().create();
+
+ private static final String UPLOAD_TEMPLATE_NAME = "Nikshay_Device_Upload_Template.xlsx";
+ private static final String XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+
+ @Autowired
+ private NikshayDeviceService nikshayDeviceService;
+
+ @Operation(summary = "Create one or more Nikshay camp devices passwords are stored encrypted. created devices " +
+ "and failed devices with row index, reason and field errors")
+ @PostMapping(value = "/create", produces = "application/json", headers = "Authorization")
+ public String createDevices(@RequestBody NikshayDeviceBulkCreateRequest request) {
+ return respond("createDevices", () -> nikshayDeviceService.createDevices(request));
+ }
+
+ @Operation(summary = "Download the Excel template for the bulk device upload")
+ @GetMapping(value = "/upload/template", headers = "Authorization")
+ public ResponseEntity downloadUploadTemplate() {
+ try {
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION,
+ ContentDisposition.attachment().filename(UPLOAD_TEMPLATE_NAME).build().toString())
+ .contentType(MediaType.parseMediaType(XLSX))
+ .body(nikshayDeviceService.getUploadTemplate());
+ } catch (Exception e) {
+ logger.error("downloadUploadTemplate failed: " + e.getMessage(), e);
+ return ResponseEntity.internalServerError().build();
+ }
+ }
+
+ @Operation(summary = "Create devices from the filled Excel template; nothing is created if any row is invalid")
+ @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = "application/json",
+ headers = "Authorization")
+ public String uploadDevices(@RequestParam("file") MultipartFile file, @RequestParam("createdBy") String createdBy) {
+ return respond("uploadDevices", () -> {
+ if (file == null || file.isEmpty()) {
+ throw new IllegalArgumentException("Excel file is required");
+ }
+ return nikshayDeviceService.uploadDevices(file.getInputStream(), createdBy);
+ });
+ }
+
+ @Operation(summary = "Update a device; the password changes only when a new one is sent")
+ @PostMapping(value = "/update", produces = "application/json", headers = "Authorization")
+ public String updateDevice(@RequestBody NikshayDeviceRequest request) {
+ return respond("updateDevice", () -> nikshayDeviceService.updateDevice(request));
+ }
+
+ @Operation(summary = "Activate / deactivate a device; deactivating also removes it from its villages")
+ @PostMapping(value = "/activate", produces = "application/json", headers = "Authorization")
+ public String setDeviceActive(@RequestBody NikshayActivateRequest request) {
+ return respond("setDeviceActive", () -> nikshayDeviceService.setDeviceActive(request));
+ }
+
+ @Operation(summary = "List devices (without password), each with its active villages, "
+ + "optionally by device name (NAAT / XRAY) and active flag")
+ @GetMapping(value = "/list", produces = "application/json", headers = "Authorization")
+ public String listDevices(@RequestParam(value = "deviceName", required = false) String deviceName,
+ @RequestParam(value = "isActive", required = false) Boolean isActive) {
+ return respond("listDevices", () -> nikshayDeviceService.listDevices(deviceName, isActive));
+ }
+
+ @Operation(summary = "Search devices by serial number (partial, case-insensitive; exact match first), "
+ + "without password, each with its active villages")
+ @GetMapping(value = "/search", produces = "application/json", headers = "Authorization")
+ public String searchByDeviceSerialNumber(@RequestParam("deviceSerialNumber") String deviceSerialNumber) {
+ return respond("searchByDeviceSerialNumber", () -> nikshayDeviceService.searchByDeviceSerialNumber(deviceSerialNumber));
+ }
+
+ @Operation(summary = "Get one device (without password) with its active villages")
+ @GetMapping(value = "/get", produces = "application/json", headers = "Authorization")
+ public String getDevice(@RequestParam("id") Integer id) {
+ return respond("getDevice", () -> nikshayDeviceService.getDevice(id));
+ }
+
+ @Operation(summary = "Enroll a device for villages picked from one or more facilities of a TBU (facilityIds, villageIds). "
+ + "A village can have one active NAAT and one active XRAY device")
+ @PostMapping(value = "/village/enroll", produces = "application/json", headers = "Authorization")
+ public String enrollVillages(@RequestBody NikshayVillageEnrollRequest request) {
+ return respond("enrollVillages", () -> nikshayDeviceService.enrollVillages(request));
+ }
+
+ @Operation(summary = "Remove a device from villages (marks the mappings inactive)")
+ @PostMapping(value = "/village/remove", produces = "application/json", headers = "Authorization")
+ public String removeVillages(@RequestBody NikshayVillageRemoveRequest request) {
+ return respond("removeVillages", () -> nikshayDeviceService.removeVillages(request));
+ }
+
+ @Operation(summary = "Villages served by a device")
+ @GetMapping(value = "/village/list", produces = "application/json", headers = "Authorization")
+ public String getVillagesOfDevice(@RequestParam("deviceDetailId") Integer deviceDetailId,
+ @RequestParam(value = "activeOnly", required = false, defaultValue = "true") Boolean activeOnly) {
+ return respond("getVillagesOfDevice", () -> nikshayDeviceService.getVillagesOfDevice(deviceDetailId, activeOnly));
+ }
+
+ @Operation(summary = "Devices and PHI details (without password) mapped to a Ni-kshay location")
+ @GetMapping(value = "/phiByLocation", produces = "application/json", headers = "Authorization")
+ public String getPhiByLocation(@RequestParam(value = "stateId", required = false) Integer stateId,
+ @RequestParam(value = "districtId", required = false) Integer districtId,
+ @RequestParam(value = "tbuId", required = false) Integer tbuId,
+ @RequestParam(value = "facilityId", required = false) Integer facilityId,
+ @RequestParam(value = "villageId", required = false) Integer villageId,
+ @RequestParam(value = "deviceName", required = false) String deviceName) {
+ return respond("getPhiByLocation", () -> nikshayDeviceService.getPhiByLocation(stateId, districtId, tbuId,
+ facilityId, villageId, deviceName));
+ }
+
+ private String respond(String action, ThrowingSupplier