From d093de2924afc8aaf7ca71526169fe2da3273053 Mon Sep 17 00:00:00 2001 From: chetansaroya Date: Mon, 26 Oct 2026 14:07:47 +0530 Subject: [PATCH] stop:647 Nikshay integration's device configuration and designated PHI mapping. --- src/main/environment/admin_ci.properties | 3 + src/main/environment/admin_docker.properties | 3 + src/main/environment/admin_example.properties | 2 + .../NikshayDesignatedPhiController.java | 107 ++++++ .../nikshay/NikshayDeviceController.java | 123 ++++++ .../nikshay/NikshayDeviceConfiguration.java | 86 +++++ .../admin/data/nikshay/NikshayDeviceName.java | 23 ++ .../nikshay/NikshayDeviceVillageMapping.java | 90 +++++ .../NikshayTransferOutPhiVillageMapping.java | 81 ++++ .../model/nikshay/NikshayActivateRequest.java | 14 + .../nikshay/NikshayDesignatedPhiRequest.java | 21 + .../model/nikshay/NikshayDeviceRequest.java | 27 ++ .../model/nikshay/NikshayDeviceResponse.java | 25 ++ .../nikshay/NikshayVillageEnrollRequest.java | 27 ++ .../nikshay/NikshayVillageRemoveRequest.java | 15 + .../NikshayDeviceConfigurationRepo.java | 27 ++ .../NikshayDeviceVillageMappingRepo.java | 63 +++ .../nikshay/NikshayTransferOutPhiRepo.java | 51 +++ .../nikshay/NikshayDesignatedPhiService.java | 21 + .../NikshayDesignatedPhiServiceImpl.java | 218 +++++++++++ .../service/nikshay/NikshayDeviceService.java | 34 ++ .../nikshay/NikshayDeviceServiceImpl.java | 363 ++++++++++++++++++ .../service/nikshay/NikshayRowUtils.java | 78 ++++ .../utils/crypto/NikshayPasswordCipher.java | 109 ++++++ 24 files changed, 1611 insertions(+) create mode 100644 src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java create mode 100644 src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceName.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceVillageMapping.java create mode 100644 src/main/java/com/iemr/admin/data/nikshay/NikshayTransferOutPhiVillageMapping.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayActivateRequest.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayDesignatedPhiRequest.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java create mode 100644 src/main/java/com/iemr/admin/model/nikshay/NikshayVillageRemoveRequest.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java create mode 100644 src/main/java/com/iemr/admin/repo/nikshay/NikshayTransferOutPhiRepo.java create mode 100644 src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java create mode 100644 src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java create mode 100644 src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java create mode 100644 src/main/java/com/iemr/admin/service/nikshay/NikshayRowUtils.java create mode 100644 src/main/java/com/iemr/admin/utils/crypto/NikshayPasswordCipher.java diff --git a/src/main/environment/admin_ci.properties b/src/main/environment/admin_ci.properties index 92823806..75042e11 100644 --- a/src/main/environment/admin_ci.properties +++ b/src/main/environment/admin_ci.properties @@ -26,3 +26,6 @@ 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@ \ No newline at end of file diff --git a/src/main/environment/admin_docker.properties b/src/main/environment/admin_docker.properties index d530138a..e21387c3 100644 --- a/src/main/environment/admin_docker.properties +++ b/src/main/environment/admin_docker.properties @@ -22,3 +22,6 @@ 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:} \ No newline at end of file diff --git a/src/main/environment/admin_example.properties b/src/main/environment/admin_example.properties index f510b42b..ecba7093 100644 --- a/src/main/environment/admin_example.properties +++ b/src/main/environment/admin_example.properties @@ -26,3 +26,5 @@ 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:} 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..a6ff8ab8 --- /dev/null +++ b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDesignatedPhiController.java @@ -0,0 +1,107 @@ +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(); + } +} 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..2a9f9eac --- /dev/null +++ b/src/main/java/com/iemr/admin/controller/nikshay/NikshayDeviceController.java @@ -0,0 +1,123 @@ +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.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(); + + @Autowired + private NikshayDeviceService nikshayDeviceService; + + @Operation(summary = "Create a Ni-kshay camp device (device name NAAT / XRAY) with its PHI login; the password is stored encrypted") + @PostMapping(value = "/create", produces = "application/json", headers = "Authorization") + public String createDevice(@RequestBody NikshayDeviceRequest request) { + return respond("createDevice", () -> nikshayDeviceService.createDevice(request)); + } + + @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), 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 device ID (partial, case-insensitive; exact match first), " + + "without password, each with its active villages") + @GetMapping(value = "/search", produces = "application/json", headers = "Authorization") + public String searchByDeviceId(@RequestParam("deviceId") String deviceId) { + return respond("searchByDeviceId", () -> nikshayDeviceService.searchByDeviceId(deviceId)); + } + + @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 one or more Ni-kshay villages. 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 call) { + OutputResponse response = new OutputResponse(); + try { + response.setResponse(GSON.toJson(call.get())); + } catch (Exception e) { + logger.error(action + " failed: " + e.getMessage(), e); + response.setError(e); + } + return response.toString(); + } + + @FunctionalInterface + interface ThrowingSupplier { + T get() throws Exception; + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java new file mode 100644 index 00000000..fcf2275e --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceConfiguration.java @@ -0,0 +1,86 @@ +package com.iemr.admin.data.nikshay; + +import java.sql.Timestamp; + +import com.google.gson.annotations.Expose; +import com.iemr.admin.utils.mapper.OutputMapper; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import lombok.Data; + +/** + * One TBMJA camp device (device_name NAAT or XRAY) and the Ni-kshay PHI login created for + * it. The Ni-kshay job logs in with the device of the beneficiary's village: + * the NAAT device for ID generation, NAAT results, contacts and transfer out, + * the XRAY device for X-ray results. + * + * The password is stored AES encrypted (see NikshayPasswordCipher) and has no + * {@link Expose}, so it is never part of toString() or any API response. + */ +@Data +@Entity +@Table(name = "m_nikshay_device_configuration") +public class NikshayDeviceConfiguration { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Expose + @Column(name = "id") + private Integer id; + + @Expose + @Column(name = "device_id", unique = true) + private String deviceId; + + @Expose + @Enumerated(EnumType.STRING) + @Column(name = "device_name") + private NikshayDeviceName deviceName; + + @Expose + @Column(name = "phi_user_id") + private String phiUserId; + + @Column(name = "password") + private String password; + + @Expose + @Column(name = "is_active") + private Boolean isActive = true; + + @Expose + @Column(name = "created_by") + private String createdBy; + + @Expose + @Column(name = "created_at", insertable = false, updatable = false) + private Timestamp createdAt; + + @Expose + @Column(name = "updated_by") + private String updatedBy; + + @Expose + @Column(name = "updated_at", insertable = false, updatable = false) + private Timestamp updatedAt; + + @Expose + @Column(name = "more_data", columnDefinition = "json") + private String moreData; + + @Transient + private OutputMapper outputMapper = new OutputMapper(); + + @Override + public String toString() { + return outputMapper.gson().toJson(this); + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceName.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceName.java new file mode 100644 index 00000000..570a3ef5 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceName.java @@ -0,0 +1,23 @@ +package com.iemr.admin.data.nikshay; + +/** + * Name of a TBMJA camp device, stored as a string in + * m_nikshay_device_configuration.device_name. It decides which activities the + * device's PHI login is used for: NAAT for ID generation, NAAT results, + * contacts and transfer out; XRAY for X-ray results. + */ +public enum NikshayDeviceName { + NAAT, XRAY; + + /** Case-insensitive parse with a readable error for API input. */ + public static NikshayDeviceName from(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Device name is required"); + } + try { + return valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Device name must be NAAT or XRAY"); + } + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceVillageMapping.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceVillageMapping.java new file mode 100644 index 00000000..d7f4c927 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayDeviceVillageMapping.java @@ -0,0 +1,90 @@ +package com.iemr.admin.data.nikshay; + +import java.sql.Timestamp; + +import com.google.gson.annotations.Expose; +import com.iemr.admin.utils.mapper.OutputMapper; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import lombok.Data; + +/** + * A village served by a device (m_nikshay_device_configuration). One device serves + * many villages; a village has at most one active device of each type (NAAT, + * XRAY). All location IDs are + * Ni-kshay IDs (m_nikshay_*). The job joins the beneficiary's + * i_beneficiaryaddress.PermVillageId with village_id to find the device login. + */ +@Data +@Entity +@Table(name = "m_nikshay_device_village_mapping") +public class NikshayDeviceVillageMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Expose + @Column(name = "id") + private Integer id; + + @Expose + @Column(name = "device_detail_id") + private Integer deviceDetailId; + + @Expose + @Column(name = "state_id") + private Integer stateId; + + @Expose + @Column(name = "district_id") + private Integer districtId; + + @Expose + @Column(name = "tbu_id") + private Integer tbuId; + + @Expose + @Column(name = "facility_id") + private Integer facilityId; + + @Expose + @Column(name = "village_id") + private Integer villageId; + + @Expose + @Column(name = "is_active") + private Boolean isActive = true; + + @Expose + @Column(name = "created_by") + private String createdBy; + + @Expose + @Column(name = "created_at", insertable = false, updatable = false) + private Timestamp createdAt; + + @Expose + @Column(name = "updated_by") + private String updatedBy; + + @Expose + @Column(name = "updated_at", insertable = false, updatable = false) + private Timestamp updatedAt; + + @Expose + @Column(name = "more_data", columnDefinition = "json") + private String moreData; + + @Transient + private OutputMapper outputMapper = new OutputMapper(); + + @Override + public String toString() { + return outputMapper.gson().toJson(this); + } +} diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayTransferOutPhiVillageMapping.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayTransferOutPhiVillageMapping.java new file mode 100644 index 00000000..193318ed --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayTransferOutPhiVillageMapping.java @@ -0,0 +1,81 @@ +package com.iemr.admin.data.nikshay; + +import java.sql.Timestamp; + +import com.google.gson.annotations.Expose; +import com.iemr.admin.utils.mapper.OutputMapper; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import lombok.Data; + +/** + * Designated PHI of a Ni-kshay village, used by the transfer out phase. A + * village can have several active rows; village + TBU + PHI is unique among + * active rows. + */ +@Data +@Entity +@Table(name = "m_nikshay_transfer_out_phi_village_mapping") +public class NikshayTransferOutPhiVillageMapping { + + public static final String STATUS_ACTIVE = "active"; + public static final String STATUS_INACTIVE = "inactive"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Expose + @Column(name = "id") + private Integer id; + + @Expose + @Column(name = "village_id") + private Integer villageId; + + @Expose + @Column(name = "tbu_id") + private Integer tbuId; + + // Ni-kshay facility ID of the designated PHI that receives the transfer + @Expose + @Column(name = "designated_phi_id") + private String designatedPhiId; + + // Ni-kshay hierarchy ID of the designated PHI + @Expose + @Column(name = "designation_hierarchy_id") + private String designationHierarchyId; + + @Expose + @Column(name = "status") + private String status = STATUS_ACTIVE; + + @Expose + @Column(name = "created_by") + private String createdBy; + + @Expose + @Column(name = "created_at", insertable = false, updatable = false) + private Timestamp createdAt; + + @Expose + @Column(name = "updated_by") + private String updatedBy; + + @Expose + @Column(name = "updated_at", insertable = false, updatable = false) + private Timestamp updatedAt; + + @Transient + private OutputMapper outputMapper = new OutputMapper(); + + @Override + public String toString() { + return outputMapper.gson().toJson(this); + } +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayActivateRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayActivateRequest.java new file mode 100644 index 00000000..b72eaa16 --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayActivateRequest.java @@ -0,0 +1,14 @@ +package com.iemr.admin.model.nikshay; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Data; + +// Activate / deactivate a device or a designated PHI mapping +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NikshayActivateRequest { + private Integer id; + private Boolean isActive; + private String updatedBy; +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayDesignatedPhiRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayDesignatedPhiRequest.java new file mode 100644 index 00000000..72e9f5f0 --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayDesignatedPhiRequest.java @@ -0,0 +1,21 @@ +package com.iemr.admin.model.nikshay; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Data; + +/** + * Designated PHI for a village. Saving the same village, TBU and designated PHI + * again updates that row (hierarchy ID) instead of adding a duplicate. + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NikshayDesignatedPhiRequest { + private Integer id; + private Integer villageId; + private Integer tbuId; + private String designatedPhiId; + private String designationHierarchyId; + private String createdBy; + private String updatedBy; +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java new file mode 100644 index 00000000..89d476ff --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceRequest.java @@ -0,0 +1,27 @@ +package com.iemr.admin.model.nikshay; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Data; + +/** + * Create / update body for a camp device. On update, password is changed only + * when a non-blank value is sent. moreData is any JSON object. + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NikshayDeviceRequest { + private Integer id; + private String deviceId; + private String deviceName; + private String phiUserId; + private String password; + private Object moreData; + private String createdBy; + private String updatedBy; + + @Override + public String toString() { + return "NikshayDeviceRequest(id=" + id + ", deviceId=" + deviceId + ", deviceName=" + deviceName + ")"; + } +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java new file mode 100644 index 00000000..55fe724a --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayDeviceResponse.java @@ -0,0 +1,25 @@ +package com.iemr.admin.model.nikshay; + +import java.util.List; +import java.util.Map; + +import com.iemr.admin.data.nikshay.NikshayDeviceName; + +import lombok.Data; + +// Device details as returned by the APIs. Deliberately has no password field. +@Data +public class NikshayDeviceResponse { + private Integer id; + private String deviceId; + private NikshayDeviceName deviceName; + private String phiUserId; + private Boolean isActive; + private Object moreData; + private Long activeVillageCount; + private List> villages; + private String createdBy; + private String createdAt; + private String updatedBy; + private String updatedAt; +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java new file mode 100644 index 00000000..0207f927 --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageEnrollRequest.java @@ -0,0 +1,27 @@ +package com.iemr.admin.model.nikshay; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Data; + +// Enroll a device (phir_device_configuration.id) for one or more Ni-kshay villages +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NikshayVillageEnrollRequest { + private Integer deviceDetailId; + private String createdBy; + private List villages; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Village { + private Integer stateId; + private Integer districtId; + private Integer tbuId; + private Integer facilityId; + private Integer villageId; + private Object moreData; + } +} diff --git a/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageRemoveRequest.java b/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageRemoveRequest.java new file mode 100644 index 00000000..eccb0db9 --- /dev/null +++ b/src/main/java/com/iemr/admin/model/nikshay/NikshayVillageRemoveRequest.java @@ -0,0 +1,15 @@ +package com.iemr.admin.model.nikshay; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Data; + +// Remove a device from villages: marks the given device_village_mapping rows inactive +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NikshayVillageRemoveRequest { + private List mappingIds; + private String updatedBy; +} diff --git a/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java new file mode 100644 index 00000000..46b7154c --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceConfigurationRepo.java @@ -0,0 +1,27 @@ +package com.iemr.admin.repo.nikshay; + +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshay.NikshayDeviceConfiguration; +import com.iemr.admin.data.nikshay.NikshayDeviceName; + +@Repository +public interface NikshayDeviceConfigurationRepo extends CrudRepository { + + NikshayDeviceConfiguration findByDeviceId(String deviceId); + + // Partial, case-insensitive match on the programme device ID; exact matches first + @Query("select d from NikshayDeviceConfiguration d where lower(d.deviceId) like lower(concat('%', :deviceId, '%')) " + + "order by case when lower(d.deviceId) = lower(:deviceId) then 0 else 1 end, d.deviceId") + List searchByDeviceId(@Param("deviceId") String deviceId); + + @Query("select d from NikshayDeviceConfiguration d where (:deviceName is null or d.deviceName = :deviceName) " + + "and (:isActive is null or d.isActive = :isActive) order by d.deviceName, d.id") + List search(@Param("deviceName") NikshayDeviceName deviceName, + @Param("isActive") Boolean isActive); +} diff --git a/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java new file mode 100644 index 00000000..4b0a22b6 --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshay/NikshayDeviceVillageMappingRepo.java @@ -0,0 +1,63 @@ +package com.iemr.admin.repo.nikshay; + +import java.util.List; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshay.NikshayDeviceName; +import com.iemr.admin.data.nikshay.NikshayDeviceVillageMapping; + +@Repository +public interface NikshayDeviceVillageMappingRepo extends CrudRepository { + + // Active mappings of these villages to devices of one type (NAAT or XRAY) + @Query("select m from NikshayDeviceVillageMapping m, NikshayDeviceConfiguration d where d.id = m.deviceDetailId " + + "and m.villageId in (:villageIds) and m.isActive = true and d.deviceName = :deviceName") + List findActiveByVillageIdsAndDeviceName(@Param("villageIds") List villageIds, + @Param("deviceName") NikshayDeviceName deviceName); + + @Query("select count(m) from NikshayDeviceVillageMapping m where m.deviceDetailId = :deviceDetailId and m.isActive = true") + long countActiveByDevice(@Param("deviceDetailId") Integer deviceDetailId); + + @Modifying + @Query("update NikshayDeviceVillageMapping m set m.isActive = false, m.updatedBy = :updatedBy " + + "where m.deviceDetailId = :deviceDetailId and m.isActive = true") + int deactivateAllForDevice(@Param("deviceDetailId") Integer deviceDetailId, @Param("updatedBy") String updatedBy); + + // row = [id, village_id, VillageName, facility_id, FacilityName, tbu_id, TUName, + // district_id, state_id, is_active, created_by, created_at] + @Query(value = "select m.id, m.village_id, v.VillageName, m.facility_id, f.FacilityName, m.tbu_id, t.TUName, " + + "m.district_id, m.state_id, m.is_active, m.created_by, m.created_at " + + "from m_nikshay_device_village_mapping m " + + "left join m_nikshay_village v on v.NikshayVillageID = m.village_id " + + "left join m_nikshay_facility f on f.NikshayFacilityID = m.facility_id " + + "left join m_nikshay_tu t on t.NikshayTUID = m.tbu_id " + + "where m.device_detail_id = :deviceDetailId and (:activeOnly = false or m.is_active = true) " + + "order by v.VillageName, m.id", nativeQuery = true) + List findVillagesOfDevice(@Param("deviceDetailId") Integer deviceDetailId, + @Param("activeOnly") Boolean activeOnly); + + // Active mappings of active devices for a location. row = [mapping id, village_id, + // VillageName, facility_id, tbu_id, district_id, state_id, device pk id, + // device_id, device_name, phi_user_id]. Never selects the password. + @Query(value = "select m.id, m.village_id, v.VillageName, m.facility_id, m.tbu_id, m.district_id, m.state_id, " + + "d.id as device_detail_id, d.device_id, d.device_name, d.phi_user_id " + + "from m_nikshay_device_village_mapping m " + + "join m_nikshay_device_configuration d on d.id = m.device_detail_id " + + "left join m_nikshay_village v on v.NikshayVillageID = m.village_id " + + "where m.is_active = true and d.is_active = true " + + "and (:stateId is null or m.state_id = :stateId) " + + "and (:districtId is null or m.district_id = :districtId) " + + "and (:tbuId is null or m.tbu_id = :tbuId) " + + "and (:facilityId is null or m.facility_id = :facilityId) " + + "and (:villageId is null or m.village_id = :villageId) " + + "and (:deviceName is null or d.device_name = :deviceName) " + + "order by v.VillageName, m.village_id", nativeQuery = true) + List findPhiByLocation(@Param("stateId") Integer stateId, @Param("districtId") Integer districtId, + @Param("tbuId") Integer tbuId, @Param("facilityId") Integer facilityId, + @Param("villageId") Integer villageId, @Param("deviceName") String deviceName); +} diff --git a/src/main/java/com/iemr/admin/repo/nikshay/NikshayTransferOutPhiRepo.java b/src/main/java/com/iemr/admin/repo/nikshay/NikshayTransferOutPhiRepo.java new file mode 100644 index 00000000..3e13897f --- /dev/null +++ b/src/main/java/com/iemr/admin/repo/nikshay/NikshayTransferOutPhiRepo.java @@ -0,0 +1,51 @@ +package com.iemr.admin.repo.nikshay; + +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.admin.data.nikshay.NikshayTransferOutPhiVillageMapping; + +@Repository +public interface NikshayTransferOutPhiRepo extends CrudRepository { + + // Active rows for a village + TBU + PHI (unique while active) + @Query("select p from NikshayTransferOutPhiVillageMapping p where p.villageId = :villageId and p.tbuId = :tbuId " + + "and p.designatedPhiId = :designatedPhiId and p.status = 'active'") + List findActiveByVillageAndPhi(@Param("villageId") Integer villageId, + @Param("tbuId") Integer tbuId, @Param("designatedPhiId") String designatedPhiId); + + // row = [id, village_id, VillageName, tbu_id, TUName, designated_phi_id, + // designation_hierarchy_id, status, created_by, created_at, updated_by, updated_at] + @Query(value = "select p.id, p.village_id, v.VillageName, p.tbu_id, t.TUName, p.designated_phi_id, p.designation_hierarchy_id, " + + "p.status, p.created_by, p.created_at, p.updated_by, p.updated_at " + + "from m_nikshay_transfer_out_phi_village_mapping p " + + "left join m_nikshay_village v on v.NikshayVillageID = p.village_id " + + "left join m_nikshay_tu t on t.NikshayTUID = p.tbu_id " + + "where (:villageId is null or p.village_id = :villageId) " + + "and (:tbuId is null or p.tbu_id = :tbuId) " + + "and (:districtId is null or t.NikshayDistrictID = :districtId) " + + "and (:activeOnly = false or p.status = 'active') " + + "order by v.VillageName, p.village_id, p.id desc", nativeQuery = true) + List search(@Param("villageId") Integer villageId, @Param("tbuId") Integer tbuId, + @Param("districtId") Integer districtId, @Param("activeOnly") Boolean activeOnly); + + // Villages with no active designated PHI. row = [NikshayVillageID, + // VillageName, NikshayFacilityID, FacilityName, NikshayTUID, TUName] + @Query(value = "select v.NikshayVillageID, v.VillageName, f.NikshayFacilityID, f.FacilityName, t.NikshayTUID, t.TUName " + + "from m_nikshay_village v " + + "join m_nikshay_facility f on f.NikshayFacilityID = v.NikshayFacilityID " + + "join m_nikshay_tu t on t.NikshayTUID = f.NikshayTUID " + + "where v.Deleted = false " + + "and (:villageId is null or v.NikshayVillageID = :villageId) " + + "and (:tbuId is null or t.NikshayTUID = :tbuId) " + + "and (:districtId is null or t.NikshayDistrictID = :districtId) " + + "and not exists (select 1 from m_nikshay_transfer_out_phi_village_mapping p " + + "where p.village_id = v.NikshayVillageID and p.status = 'active') " + + "order by v.VillageName, v.NikshayVillageID", nativeQuery = true) + List findVillagesWithoutDesignatedPhi(@Param("villageId") Integer villageId, + @Param("tbuId") Integer tbuId, @Param("districtId") Integer districtId); +} diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java new file mode 100644 index 00000000..453d2dfe --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiService.java @@ -0,0 +1,21 @@ +package com.iemr.admin.service.nikshay; + +import java.util.List; +import java.util.Map; + +import com.iemr.admin.model.nikshay.NikshayActivateRequest; +import com.iemr.admin.model.nikshay.NikshayDesignatedPhiRequest; + +public interface NikshayDesignatedPhiService { + + Map saveDesignatedPhi(NikshayDesignatedPhiRequest request) throws Exception; + + Map updateDesignatedPhi(NikshayDesignatedPhiRequest request) throws Exception; + + Map setActive(NikshayActivateRequest request) throws Exception; + + Map markInactive(NikshayActivateRequest request) throws Exception; + + List> getDesignatedPhi(Integer villageId, Integer tbuId, Integer districtId, + Boolean activeOnly, Boolean unmappedOnly) throws Exception; +} diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java new file mode 100644 index 00000000..81d83f74 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDesignatedPhiServiceImpl.java @@ -0,0 +1,218 @@ +package com.iemr.admin.service.nikshay; + +import static com.iemr.admin.service.nikshay.NikshayRowUtils.require; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.str; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.toInt; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.trimToNull; + +import java.util.ArrayList; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.admin.data.nikshay.NikshayTransferOutPhiVillageMapping; +import com.iemr.admin.model.nikshay.NikshayActivateRequest; +import com.iemr.admin.model.nikshay.NikshayDesignatedPhiRequest; +import com.iemr.admin.repo.nikshay.NikshayTransferOutPhiRepo; +import com.iemr.admin.repo.nikshay.NikshayVillageRepo; + +/** + * Designated PHI of each Ni-kshay village, read by the transfer out phase. + * A village can have several active rows; village + TBU + PHI is unique among + * active rows. Saving a village + TBU + PHI that is already active updates that row. + */ +@Service +public class NikshayDesignatedPhiServiceImpl implements NikshayDesignatedPhiService { + private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); + + @Autowired + private NikshayTransferOutPhiRepo transferOutPhiRepo; + + @Autowired + private NikshayVillageRepo nikshayVillageRepo; + + @Override + @Transactional(rollbackFor = Exception.class) + public Map saveDesignatedPhi(NikshayDesignatedPhiRequest request) throws Exception { + require(request, "Request body is required"); + Integer villageId = require(request.getVillageId(), "Village is required"); + Integer tbuId = require(request.getTbuId(), "TBU is required"); + String phiId = require(request.getDesignatedPhiId(), "Designated PHI is required"); + String hierarchyId = trimToNull(request.getDesignationHierarchyId()); + String createdBy = require(request.getCreatedBy(), "Created by is required"); + requireVillage(villageId); + + // Same village + TBU + PHI already active: update it instead of adding a duplicate + List existing = transferOutPhiRepo.findActiveByVillageAndPhi(villageId, + tbuId, phiId); + NikshayTransferOutPhiVillageMapping mapping; + if (existing.isEmpty()) { + mapping = new NikshayTransferOutPhiVillageMapping(); + mapping.setVillageId(villageId); + mapping.setTbuId(tbuId); + mapping.setDesignatedPhiId(phiId); + mapping.setStatus(NikshayTransferOutPhiVillageMapping.STATUS_ACTIVE); + mapping.setCreatedBy(createdBy); + } else { + mapping = existing.get(0); + mapping.setUpdatedBy(createdBy); + } + mapping.setDesignationHierarchyId(hierarchyId); + mapping = transferOutPhiRepo.save(mapping); + logger.info("Designated PHI {} {} for village {} by {}", phiId, existing.isEmpty() ? "added" : "updated", + villageId, createdBy); + return toMap(mapping); + } + + /** + * Edits a mapping by id. Only id and updatedBy are required; every other + * field is changed only when it is sent (not null / not blank). Rejected + * when it would make a second active row for the same village + TBU + PHI. + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Map updateDesignatedPhi(NikshayDesignatedPhiRequest request) throws Exception { + require(request, "Request body is required"); + Integer id = require(request.getId(), "Mapping id is required"); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + NikshayTransferOutPhiVillageMapping mapping = transferOutPhiRepo.findById(id) + .orElseThrow(() -> new IllegalArgumentException("No designated PHI mapping found with id " + id)); + + // Only the fields that are sent (not null / not blank) are changed + if (request.getVillageId() != null) { + requireVillage(request.getVillageId()); + mapping.setVillageId(request.getVillageId()); + } + if (request.getTbuId() != null) { + mapping.setTbuId(request.getTbuId()); + } + if (trimToNull(request.getDesignatedPhiId()) != null) { + mapping.setDesignatedPhiId(request.getDesignatedPhiId().trim()); + } + if (trimToNull(request.getDesignationHierarchyId()) != null) { + mapping.setDesignationHierarchyId(request.getDesignationHierarchyId().trim()); + } + if (NikshayTransferOutPhiVillageMapping.STATUS_ACTIVE.equals(mapping.getStatus())) { + rejectDuplicateActive(id, mapping.getVillageId(), mapping.getTbuId(), mapping.getDesignatedPhiId()); + } + mapping.setUpdatedBy(updatedBy); + mapping = transferOutPhiRepo.save(mapping); + logger.info("Designated PHI mapping {} updated by {}", id, updatedBy); + return toMap(mapping); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map setActive(NikshayActivateRequest request) throws Exception { + require(request, "Request body is required"); + Integer id = require(request.getId(), "Mapping id is required"); + Boolean active = require(request.getIsActive(), "isActive is required"); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + NikshayTransferOutPhiVillageMapping mapping = transferOutPhiRepo.findById(id) + .orElseThrow(() -> new IllegalArgumentException("No designated PHI mapping found with id " + id)); + + if (active) { + rejectDuplicateActive(mapping.getId(), mapping.getVillageId(), mapping.getTbuId(), + mapping.getDesignatedPhiId()); + } + mapping.setStatus(active ? NikshayTransferOutPhiVillageMapping.STATUS_ACTIVE + : NikshayTransferOutPhiVillageMapping.STATUS_INACTIVE); + mapping.setUpdatedBy(updatedBy); + return toMap(transferOutPhiRepo.save(mapping)); + } + + // Only id and updatedBy are used; an already inactive mapping is returned unchanged + @Override + @Transactional(rollbackFor = Exception.class) + public Map markInactive(NikshayActivateRequest request) throws Exception { + require(request, "Request body is required"); + Integer id = require(request.getId(), "Mapping id is required"); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + NikshayTransferOutPhiVillageMapping mapping = transferOutPhiRepo.findById(id) + .orElseThrow(() -> new IllegalArgumentException("No designated PHI mapping found with id " + id)); + + if (NikshayTransferOutPhiVillageMapping.STATUS_INACTIVE.equals(mapping.getStatus())) { + return toMap(mapping); + } + mapping.setStatus(NikshayTransferOutPhiVillageMapping.STATUS_INACTIVE); + mapping.setUpdatedBy(updatedBy); + mapping = transferOutPhiRepo.save(mapping); + logger.info("Designated PHI mapping {} marked inactive by {}", id, updatedBy); + return toMap(mapping); + } + + @Override + public List> getDesignatedPhi(Integer villageId, Integer tbuId, Integer districtId, + Boolean activeOnly, Boolean unmappedOnly) throws Exception { + if (villageId == null && tbuId == null && districtId == null) { + throw new IllegalArgumentException("Village, TBU or district is required"); + } + List> result = new ArrayList<>(); + if (Boolean.TRUE.equals(unmappedOnly)) { + // row layout: see NikshayTransferOutPhiRepo.findVillagesWithoutDesignatedPhi + for (Object[] row : transferOutPhiRepo.findVillagesWithoutDesignatedPhi(villageId, tbuId, districtId)) { + Map m = new LinkedHashMap<>(); + m.put("villageId", toInt(row[0])); + m.put("villageName", row[1]); + m.put("facilityId", toInt(row[2])); + m.put("facilityName", row[3]); + m.put("tbuId", toInt(row[4])); + m.put("tbuName", row[5]); + result.add(m); + } + return result; + } + // row layout: see NikshayTransferOutPhiRepo.search + for (Object[] row : transferOutPhiRepo.search(villageId, tbuId, districtId, !Boolean.FALSE.equals(activeOnly))) { + Map m = new LinkedHashMap<>(); + m.put("id", toInt(row[0])); + m.put("villageId", toInt(row[1])); + m.put("villageName", row[2]); + m.put("tbuId", toInt(row[3])); + m.put("tbuName", row[4]); + m.put("designatedPhiId", str(row[5])); + m.put("designationHierarchyId", str(row[6])); + m.put("status", row[7]); + m.put("createdBy", row[8]); + m.put("createdAt", str(row[9])); + m.put("updatedBy", row[10]); + m.put("updatedAt", str(row[11])); + result.add(m); + } + return result; + } + + private void requireVillage(Integer villageId) { + nikshayVillageRepo.findById(villageId) + .filter(v -> !Boolean.TRUE.equals(v.getDeleted())) + .orElseThrow(() -> new IllegalArgumentException("No Ni-kshay village found with id " + villageId)); + } + + // A village may have several PHIs, but each village + TBU + PHI only once as active + private void rejectDuplicateActive(Integer mappingId, Integer villageId, Integer tbuId, String phiId) { + for (NikshayTransferOutPhiVillageMapping other : transferOutPhiRepo.findActiveByVillageAndPhi(villageId, tbuId, + phiId)) { + if (!other.getId().equals(mappingId)) { + throw new IllegalArgumentException("Village " + villageId + " already has designated PHI " + phiId + + " active under TBU " + tbuId + " (mapping " + other.getId() + ")"); + } + } + } + + private Map toMap(NikshayTransferOutPhiVillageMapping p) { + Map m = new LinkedHashMap<>(); + m.put("id", p.getId()); + m.put("villageId", p.getVillageId()); + m.put("tbuId", p.getTbuId()); + m.put("designatedPhiId", p.getDesignatedPhiId()); + m.put("designationHierarchyId", p.getDesignationHierarchyId()); + m.put("status", p.getStatus()); + return m; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java new file mode 100644 index 00000000..03b9b952 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceService.java @@ -0,0 +1,34 @@ +package com.iemr.admin.service.nikshay; + +import java.util.List; +import java.util.Map; + +import com.iemr.admin.model.nikshay.NikshayActivateRequest; +import com.iemr.admin.model.nikshay.NikshayDeviceRequest; +import com.iemr.admin.model.nikshay.NikshayDeviceResponse; +import com.iemr.admin.model.nikshay.NikshayVillageEnrollRequest; +import com.iemr.admin.model.nikshay.NikshayVillageRemoveRequest; + +public interface NikshayDeviceService { + + NikshayDeviceResponse createDevice(NikshayDeviceRequest request) throws Exception; + + NikshayDeviceResponse updateDevice(NikshayDeviceRequest request) throws Exception; + + NikshayDeviceResponse setDeviceActive(NikshayActivateRequest request) throws Exception; + + List listDevices(String deviceName, Boolean isActive) throws Exception; + + NikshayDeviceResponse getDevice(Integer id) throws Exception; + + List searchByDeviceId(String deviceId) throws Exception; + + Map enrollVillages(NikshayVillageEnrollRequest request) throws Exception; + + Map removeVillages(NikshayVillageRemoveRequest request) throws Exception; + + List> getVillagesOfDevice(Integer deviceDetailId, Boolean activeOnly) throws Exception; + + List> getPhiByLocation(Integer stateId, Integer districtId, Integer tbuId, + Integer facilityId, Integer villageId, String deviceName) throws Exception; +} diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java new file mode 100644 index 00000000..b4300342 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayDeviceServiceImpl.java @@ -0,0 +1,363 @@ +package com.iemr.admin.service.nikshay; + +import static com.iemr.admin.service.nikshay.NikshayRowUtils.fromJson; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.require; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.str; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.toBool; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.toInt; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.toJson; +import static com.iemr.admin.service.nikshay.NikshayRowUtils.trimToNull; + +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.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.admin.data.nikshay.NikshayDeviceConfiguration; +import com.iemr.admin.data.nikshay.NikshayDeviceName; +import com.iemr.admin.data.nikshay.NikshayDeviceVillageMapping; +import com.iemr.admin.data.nikshay.NikshayVillage; +import com.iemr.admin.model.nikshay.NikshayActivateRequest; +import com.iemr.admin.model.nikshay.NikshayDeviceRequest; +import com.iemr.admin.model.nikshay.NikshayDeviceResponse; +import com.iemr.admin.model.nikshay.NikshayVillageEnrollRequest; +import com.iemr.admin.model.nikshay.NikshayVillageRemoveRequest; +import com.iemr.admin.repo.nikshay.NikshayDeviceConfigurationRepo; +import com.iemr.admin.repo.nikshay.NikshayDeviceVillageMappingRepo; +import com.iemr.admin.repo.nikshay.NikshayVillageRepo; +import com.iemr.admin.utils.crypto.NikshayPasswordCipher; + +/** + * Camp devices (NAAT / XRAY) with their Ni-kshay PHI login, and the villages + * each device serves. A device serves many villages; a village has at most one + * active device of each type (one NAAT, one XRAY), so the Ni-kshay job always + * finds exactly one login of the type it needs for a beneficiary's village. Passwords are encrypted before saving and never + * returned or logged. + */ +@Service +public class NikshayDeviceServiceImpl implements NikshayDeviceService { + private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); + + @Autowired + private NikshayDeviceConfigurationRepo deviceRepo; + + @Autowired + private NikshayDeviceVillageMappingRepo mappingRepo; + + @Autowired + private NikshayVillageRepo nikshayVillageRepo; + + @Autowired + private NikshayPasswordCipher passwordCipher; + + @Override + @Transactional(rollbackFor = Exception.class) + public NikshayDeviceResponse createDevice(NikshayDeviceRequest request) throws Exception { + require(request, "Request body is required"); + String deviceId = require(request.getDeviceId(), "Device ID is required"); + String password = require(request.getPassword(), "Password is required"); + String createdBy = require(request.getCreatedBy(), "Created by is required"); + + if (deviceRepo.findByDeviceId(deviceId) != null) { + throw new IllegalArgumentException("A device with Device ID " + deviceId + " already exists"); + } + + NikshayDeviceConfiguration device = new NikshayDeviceConfiguration(); + device.setDeviceId(deviceId); + device.setDeviceName(NikshayDeviceName.from(request.getDeviceName())); + device.setPhiUserId(require(request.getPhiUserId(), "PHI user ID is required")); + device.setPassword(passwordCipher.encrypt(password)); + device.setIsActive(true); + device.setMoreData(toJson(request.getMoreData())); + device.setCreatedBy(createdBy); + device = deviceRepo.save(device); + logger.info("Nikshay device {} created by {}", deviceId, createdBy); + return toResponse(device); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public NikshayDeviceResponse updateDevice(NikshayDeviceRequest request) throws Exception { + require(request, "Request body is required"); + NikshayDeviceConfiguration device = findDevice(require(request.getId(), "Device id is required")); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + + String deviceId = trimToNull(request.getDeviceId()); + if (deviceId != null && !deviceId.equals(device.getDeviceId())) { + NikshayDeviceConfiguration other = deviceRepo.findByDeviceId(deviceId); + if (other != null && !other.getId().equals(device.getId())) { + throw new IllegalArgumentException("A device with Device ID " + deviceId + " already exists"); + } + device.setDeviceId(deviceId); + } + if (trimToNull(request.getDeviceName()) != null) { + device.setDeviceName(NikshayDeviceName.from(request.getDeviceName())); + } + if (trimToNull(request.getPhiUserId()) != null) { + device.setPhiUserId(request.getPhiUserId().trim()); + } + + if (trimToNull(request.getPassword()) != null) { + device.setPassword(passwordCipher.encrypt(request.getPassword().trim())); + } + if (request.getMoreData() != null) { + device.setMoreData(toJson(request.getMoreData())); + } + device.setUpdatedBy(updatedBy); + device = deviceRepo.save(device); + logger.info("Nikshay device {} updated by {}", device.getDeviceId(), updatedBy); + return toResponse(device); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public NikshayDeviceResponse setDeviceActive(NikshayActivateRequest request) throws Exception { + require(request, "Request body is required"); + NikshayDeviceConfiguration device = findDevice(require(request.getId(), "Device id is required")); + Boolean active = require(request.getIsActive(), "isActive is required"); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + + device.setIsActive(active); + device.setUpdatedBy(updatedBy); + device = deviceRepo.save(device); + // An inactive device must not keep its villages, otherwise those villages + // would look covered while the job ignores the device. On re-activation + // the villages have to be enrolled again. + if (!active) { + int released = mappingRepo.deactivateAllForDevice(device.getId(), updatedBy); + logger.info("Nikshay device {} deactivated by {}, {} village mapping(s) released", device.getDeviceId(), + updatedBy, released); + } + return toResponse(device); + } + + @Override + public List listDevices(String deviceName, Boolean isActive) throws Exception { + NikshayDeviceName name = trimToNull(deviceName) == null ? null : NikshayDeviceName.from(deviceName); + List result = new ArrayList<>(); + for (NikshayDeviceConfiguration device : deviceRepo.search(name, isActive)) { + result.add(toResponse(device)); + } + return result; + } + + @Override + public List searchByDeviceId(String deviceId) throws Exception { + String term = require(deviceId, "Device ID is required"); + List result = new ArrayList<>(); + for (NikshayDeviceConfiguration device : deviceRepo.searchByDeviceId(term)) { + NikshayDeviceResponse response = toResponse(device); + response.setVillages(getVillagesOfDevice(device.getId(), true)); + result.add(response); + } + return result; + } + + @Override + public NikshayDeviceResponse getDevice(Integer id) throws Exception { + NikshayDeviceResponse response = toResponse(findDevice(require(id, "Device id is required"))); + response.setVillages(getVillagesOfDevice(id, true)); + return response; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map enrollVillages(NikshayVillageEnrollRequest request) throws Exception { + require(request, "Request body is required"); + NikshayDeviceConfiguration device = findDevice(require(request.getDeviceDetailId(), "Device id is required")); + String createdBy = require(request.getCreatedBy(), "Created by is required"); + return enroll(device, request.getVillages(), createdBy); + } + + /** + * Maps an active device to villages. Villages already mapped to this device + * are skipped; the whole call is rejected if any village is actively mapped + * to another device of the same type (a village has one NAAT and one XRAY). + */ + private Map enroll(NikshayDeviceConfiguration device, List villages, + String createdBy) { + if (!Boolean.TRUE.equals(device.getIsActive())) { + throw new IllegalArgumentException("Device " + device.getDeviceId() + " is inactive"); + } + if (villages == null || villages.isEmpty()) { + throw new IllegalArgumentException("At least one village is required"); + } + + // One entry per village; the first occurrence wins + Map byVillage = new LinkedHashMap<>(); + for (NikshayVillageEnrollRequest.Village v : villages) { + require(v, "Village entry is required"); + require(v.getStateId(), "State is required"); + require(v.getDistrictId(), "District is required"); + require(v.getTbuId(), "TBU is required"); + require(v.getFacilityId(), "Facility is required"); + byVillage.putIfAbsent(require(v.getVillageId(), "Village is required"), v); + } + List villageIds = new ArrayList<>(byVillage.keySet()); + + Set known = new HashSet<>(); + for (NikshayVillage v : nikshayVillageRepo.findAllById(villageIds)) { + if (!Boolean.TRUE.equals(v.getDeleted())) { + known.add(v.getNikshayVillageID()); + } + } + List unknown = villageIds.stream().filter(id -> !known.contains(id)).collect(Collectors.toList()); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException("Ni-kshay village(s) not found: " + unknown); + } + + Set alreadyMapped = new HashSet<>(); + List mappedToOther = new ArrayList<>(); + for (NikshayDeviceVillageMapping m : mappingRepo.findActiveByVillageIdsAndDeviceName(villageIds, + device.getDeviceName())) { + if (m.getDeviceDetailId().equals(device.getId())) { + alreadyMapped.add(m.getVillageId()); + } else { + mappedToOther.add(m.getVillageId()); + } + } + if (!mappedToOther.isEmpty()) { + throw new IllegalArgumentException("Village(s) " + mappedToOther + " already have an active " + + device.getDeviceName() + " device. Remove them from that device first."); + } + + List toSave = new ArrayList<>(); + for (Map.Entry e : byVillage.entrySet()) { + if (alreadyMapped.contains(e.getKey())) { + continue; + } + NikshayVillageEnrollRequest.Village v = e.getValue(); + NikshayDeviceVillageMapping m = new NikshayDeviceVillageMapping(); + m.setDeviceDetailId(device.getId()); + m.setStateId(v.getStateId()); + m.setDistrictId(v.getDistrictId()); + m.setTbuId(v.getTbuId()); + m.setFacilityId(v.getFacilityId()); + m.setVillageId(e.getKey()); + m.setIsActive(true); + m.setMoreData(toJson(v.getMoreData())); + m.setCreatedBy(createdBy); + toSave.add(m); + } + mappingRepo.saveAll(toSave); + logger.info("Nikshay device {} enrolled for {} village(s) by {}", device.getDeviceId(), toSave.size(), createdBy); + + Map result = new LinkedHashMap<>(); + result.put("deviceDetailId", device.getId()); + result.put("enrolledCount", toSave.size()); + result.put("alreadyMappedVillageIds", new ArrayList<>(alreadyMapped)); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map removeVillages(NikshayVillageRemoveRequest request) throws Exception { + require(request, "Request body is required"); + String updatedBy = require(request.getUpdatedBy(), "Updated by is required"); + if (request.getMappingIds() == null || request.getMappingIds().isEmpty()) { + throw new IllegalArgumentException("At least one mapping id is required"); + } + List ids = request.getMappingIds().stream().distinct().collect(Collectors.toList()); + List rows = new ArrayList<>(); + mappingRepo.findAllById(ids).forEach(rows::add); + if (rows.size() != ids.size()) { + Set found = rows.stream().map(NikshayDeviceVillageMapping::getId).collect(Collectors.toSet()); + throw new IllegalArgumentException("Mapping(s) not found: " + + ids.stream().filter(id -> !found.contains(id)).collect(Collectors.toList())); + } + int removed = 0; + for (NikshayDeviceVillageMapping m : rows) { + if (Boolean.TRUE.equals(m.getIsActive())) { + m.setIsActive(false); + m.setUpdatedBy(updatedBy); + removed++; + } + } + mappingRepo.saveAll(rows); + + Map result = new LinkedHashMap<>(); + result.put("removedCount", removed); + return result; + } + + @Override + public List> getVillagesOfDevice(Integer deviceDetailId, Boolean activeOnly) throws Exception { + require(deviceDetailId, "Device id is required"); + List> result = new ArrayList<>(); + // row layout: see NikshayDeviceVillageMappingRepo.findVillagesOfDevice + for (Object[] row : mappingRepo.findVillagesOfDevice(deviceDetailId, !Boolean.FALSE.equals(activeOnly))) { + Map m = new LinkedHashMap<>(); + m.put("mappingId", toInt(row[0])); + m.put("villageId", toInt(row[1])); + m.put("villageName", row[2]); + m.put("facilityId", toInt(row[3])); + m.put("facilityName", row[4]); + m.put("tbuId", toInt(row[5])); + m.put("tbuName", row[6]); + m.put("districtId", toInt(row[7])); + m.put("stateId", toInt(row[8])); + m.put("isActive", toBool(row[9])); + m.put("createdBy", row[10]); + m.put("createdAt", str(row[11])); + result.add(m); + } + return result; + } + + @Override + public List> getPhiByLocation(Integer stateId, Integer districtId, Integer tbuId, + Integer facilityId, Integer villageId, String deviceName) throws Exception { + if (stateId == null && districtId == null && tbuId == null && facilityId == null && villageId == null) { + throw new IllegalArgumentException("At least one of state, district, TBU, facility or village is required"); + } + String name = trimToNull(deviceName) == null ? null : NikshayDeviceName.from(deviceName).name(); + List> result = new ArrayList<>(); + // row layout: see NikshayDeviceVillageMappingRepo.findPhiByLocation + for (Object[] row : mappingRepo.findPhiByLocation(stateId, districtId, tbuId, facilityId, villageId, name)) { + Map m = new LinkedHashMap<>(); + m.put("mappingId", toInt(row[0])); + m.put("villageId", toInt(row[1])); + m.put("villageName", row[2]); + m.put("facilityId", toInt(row[3])); + m.put("tbuId", toInt(row[4])); + m.put("districtId", toInt(row[5])); + m.put("stateId", toInt(row[6])); + m.put("deviceDetailId", toInt(row[7])); + m.put("deviceId", row[8]); + m.put("deviceName", row[9]); + m.put("phiUserId", row[10]); + result.add(m); + } + return result; + } + + private NikshayDeviceConfiguration findDevice(Integer id) { + return deviceRepo.findById(id) + .orElseThrow(() -> new IllegalArgumentException("No device found with id " + id)); + } + + private NikshayDeviceResponse toResponse(NikshayDeviceConfiguration d) { + NikshayDeviceResponse r = new NikshayDeviceResponse(); + r.setId(d.getId()); + r.setDeviceId(d.getDeviceId()); + r.setDeviceName(d.getDeviceName()); + r.setPhiUserId(d.getPhiUserId()); + r.setIsActive(d.getIsActive()); + r.setMoreData(fromJson(d.getMoreData())); + r.setActiveVillageCount(d.getId() == null ? 0L : mappingRepo.countActiveByDevice(d.getId())); + r.setCreatedBy(d.getCreatedBy()); + r.setCreatedAt(str(d.getCreatedAt())); + r.setUpdatedBy(d.getUpdatedBy()); + r.setUpdatedAt(str(d.getUpdatedAt())); + return r; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshay/NikshayRowUtils.java b/src/main/java/com/iemr/admin/service/nikshay/NikshayRowUtils.java new file mode 100644 index 00000000..9a221117 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshay/NikshayRowUtils.java @@ -0,0 +1,78 @@ +package com.iemr.admin.service.nikshay; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +/** + * Small helpers shared by the Ni-kshay device / designated PHI services for + * request trimming, more_data JSON and reading native query rows (whose + * column types vary by driver: BIT -> Boolean, TINYINT -> Number, etc.). + */ +final class NikshayRowUtils { + + private static final Gson GSON = new Gson(); + + private NikshayRowUtils() { + } + + static String trimToNull(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + static String require(String value, String message) { + String t = trimToNull(value); + if (t == null) { + throw new IllegalArgumentException(message); + } + return t; + } + + static T require(T value, String message) { + if (value == null) { + throw new IllegalArgumentException(message); + } + return value; + } + + // moreData arrives as any JSON value; stored as a JSON string + static String toJson(Object moreData) { + return moreData == null ? null : GSON.toJson(moreData); + } + + static Object fromJson(String moreData) { + if (moreData == null || moreData.isBlank()) { + return null; + } + try { + return GSON.fromJson(JsonParser.parseString(moreData), Object.class); + } catch (JsonParseException e) { + return moreData; + } + } + + static String str(Object o) { + return o == null ? null : o.toString(); + } + + static Integer toInt(Object o) { + return o == null ? null : ((Number) o).intValue(); + } + + static Boolean toBool(Object o) { + if (o == null) { + return null; + } + if (o instanceof Boolean) { + return (Boolean) o; + } + if (o instanceof Number) { + return ((Number) o).intValue() != 0; + } + return Boolean.parseBoolean(o.toString()); + } +} diff --git a/src/main/java/com/iemr/admin/utils/crypto/NikshayPasswordCipher.java b/src/main/java/com/iemr/admin/utils/crypto/NikshayPasswordCipher.java new file mode 100644 index 00000000..c8a4e015 --- /dev/null +++ b/src/main/java/com/iemr/admin/utils/crypto/NikshayPasswordCipher.java @@ -0,0 +1,109 @@ +package com.iemr.admin.utils.crypto; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; + +/** + * Encrypts the Ni-kshay PHI password of a camp device before it is stored in + * phir_device_configuration.password. + * + * Format (MMU-API's Ni-kshay job must decrypt the same way, with the same key): + * AES/GCM/NoPadding, 12-byte random IV, 128-bit tag; stored value is + * Base64( IV || ciphertext+tag ). + * + * The key is Base64 of 16, 24 or 32 random bytes (e.g. `openssl rand -base64 32`), + * read from the NIKSHAY_DEVICE_AES_KEY environment variable through the + * nikshay.device.aes-key property. It is never logged. When it is missing or + * invalid the app still starts, but saving a device password fails with a + * clear error. + */ +@Component +public class NikshayPasswordCipher { + + private final Logger logger = LoggerFactory.getLogger(NikshayPasswordCipher.class); + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int IV_LENGTH = 12; + private static final int TAG_LENGTH_BITS = 128; + + private final SecureRandom secureRandom = new SecureRandom(); + + @Value("${nikshay.device.aes-key:}") + private String base64Key; + + private SecretKey key; + + @PostConstruct + void init() { + if (base64Key == null || base64Key.isBlank()) { + logger.warn("nikshay.device.aes-key (NIKSHAY_DEVICE_AES_KEY) is not set; Ni-kshay device passwords cannot be saved"); + return; + } + // A bad key only disables device password saving; it must not stop the + // rest of Admin-API from starting + byte[] raw; + try { + raw = Base64.getDecoder().decode(base64Key.trim()); + } catch (IllegalArgumentException e) { + logger.error("nikshay.device.aes-key is not valid Base64; Ni-kshay device passwords cannot be saved"); + return; + } + if (raw.length != 16 && raw.length != 24 && raw.length != 32) { + logger.error("nikshay.device.aes-key must decode to 16, 24 or 32 bytes; Ni-kshay device passwords cannot be saved"); + return; + } + key = new SecretKeySpec(raw, "AES"); + } + + public String encrypt(String plainText) { + SecretKey k = requireKey(); + try { + byte[] iv = new byte[IV_LENGTH]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, k, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder() + .encodeToString(ByteBuffer.allocate(iv.length + cipherText.length).put(iv).put(cipherText).array()); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Could not encrypt device password", e); + } + } + + // Not used by Admin-API itself; kept next to encrypt() so the format is + // defined in one place (MMU-API mirrors this). + public String decrypt(String stored) { + SecretKey k = requireKey(); + try { + byte[] all = Base64.getDecoder().decode(stored); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, k, new GCMParameterSpec(TAG_LENGTH_BITS, all, 0, IV_LENGTH)); + return new String(cipher.doFinal(all, IV_LENGTH, all.length - IV_LENGTH), StandardCharsets.UTF_8); + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new IllegalStateException("Could not decrypt device password"); + } + } + + private SecretKey requireKey() { + if (key == null) { + throw new IllegalStateException( + "Device password encryption key is not configured (NIKSHAY_DEVICE_AES_KEY). Contact the administrator."); + } + return key; + } +}