diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..ae3f45c7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md - Identity-API + +## Project Overview + +Identity-API is the beneficiary identity management service for the AMRIT platform. It handles beneficiary creation, search, update, and deduplication across dual database profiles (`db_iemr` main and `db_identity`). It supports RMNCH (Reproductive, Maternal, Newborn, Child, and Adolescent Health) data management, family tagging, Elasticsearch-based beneficiary search, and health ID linkage. + +## Tech Stack + +- Java 17, Spring Boot 3.2.2, Maven +- Spring Data JPA / Hibernate, MySQL 8.0 +- Elasticsearch (Spring Data Elasticsearch for beneficiary search indexing) +- Redis for session management +- Lombok (1.18.36), MapStruct +- SpringDoc OpenAPI (Swagger UI at `/swagger-ui.html`) +- ECS logging (logback-ecs-encoder) +- JaCoCo for test coverage +- Packaged as WAR for Wildfly deployment + +## Build & Run + +```bash +mvn clean install -DENV_VAR=local # Build +mvn spring-boot:run -DENV_VAR=local # Run locally +mvn -B package --file pom.xml -P # Package WAR (dev, local, test, ci, uat) +mvn test # Run tests +``` + +Environment config: `src/main/resources/common_.properties` is copied to `application.properties` at build time. + +## Key Packages (`com.iemr.common.identity`) + +- **controller/** - REST endpoints: + - `IdentityController` - Core beneficiary CRUD (create, search, update, search by phone/ID/name) + - `IdentityESController` - Elasticsearch-based beneficiary search + - `rmnch/RMNCHMobileAppController` - RMNCH mobile app data sync + - `familyTagging/FamilyTaggingController` - Family tagging and family search + - `elasticsearch/ElasticsearchSyncController` - Elasticsearch sync management + - `health/HealthController` - Health check endpoint + - `version/VersionController` - API version info +- **service/** - Business logic: + - `IdentityService` - Core identity operations + - `rmnch/` - RMNCH beneficiary management + - `familyTagging/` - Family tagging logic + - `elasticsearch/` - Elasticsearch indexing and sync + - `health/` - Health check service +- **domain/** - Core JPA entities for beneficiary data: + - `MBeneficiaryregidmapping` - Beneficiary registration ID mapping + - `MBeneficiaryaddress`, `MBeneficiarycontact`, `MBeneficiaryAccount` - Beneficiary demographics + - `MBeneficiaryfamilymapping` - Family relationships + - `MBeneficiaryconsent` - Consent management + - `VBenAdvanceSearch` - View for advanced search queries +- **data/** - Additional data models: + - `rmnch/` - RMNCH-specific entities (CBAC details, born birth details, household details, NCD/TB/HRP data) + - `elasticsearch/` - Elasticsearch document models and sync job + - `familyTagging/` - Family tagging models +- **dto/** - Data transfer objects for API requests/responses +- **repo/** - Spring Data JPA and Elasticsearch repositories +- **mapper/** - MapStruct mappers for entity-DTO conversion +- **filter/** - Servlet filters +- **security/** - Security configuration +- **utils/** - Utilities (Redis, HTTP, validation, session, gateway, email, exception handling) +- **config/** - Application configuration + +## Architecture Notes + +- Dual-profile beneficiary storage: main identity in `db_identity`, with mapping to `db_iemr` for AMRIT platform integration +- Elasticsearch integration provides fast full-text beneficiary search with background sync jobs +- RMNCH module handles field-worker mobile app data (CBAC screening, household surveys, birth details) +- Family tagging enables linking beneficiaries into family units with search by family ID +- MapStruct mappers handle complex entity-to-DTO transformations +- Health ID (ABHA) linkage stored per beneficiary for ABDM integration +- Beneficiary deduplication logic via advanced search views +- Artifact ID: `identity-api`, group: `com.iemr.common.identity`, version: 3.6.1 diff --git a/pom.xml b/pom.xml index f29361fd..c3b57f1c 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.iemr.common.identity identity-api - 3.6.1 + 3.6.2 war diff --git a/src/main/environment/1097_ci.properties b/src/main/environment/1097_ci.properties index 0e8deded..514f675d 100644 --- a/src/main/environment/1097_ci.properties +++ b/src/main/environment/1097_ci.properties @@ -24,7 +24,6 @@ spring.redis.host=@env.REDIS_HOST@ cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@ - # Elasticsearch Configuration elasticsearch.host=@env.ELASTICSEARCH_HOST@ elasticsearch.port=@env.ELASTICSEARCH_PORT@ diff --git a/src/main/environment/1097_example.properties b/src/main/environment/1097_example.properties index ab87d360..ba2f3211 100644 --- a/src/main/environment/1097_example.properties +++ b/src/main/environment/1097_example.properties @@ -31,4 +31,3 @@ elasticsearch.index.beneficiary=beneficiary_index # Enable/Disable ES (for gradual rollout) elasticsearch.enabled=true - diff --git a/src/main/java/com/iemr/common/identity/controller/familyTagging/FamilyTaggingController.java b/src/main/java/com/iemr/common/identity/controller/familyTagging/FamilyTaggingController.java index 19323611..b347bd50 100644 --- a/src/main/java/com/iemr/common/identity/controller/familyTagging/FamilyTaggingController.java +++ b/src/main/java/com/iemr/common/identity/controller/familyTagging/FamilyTaggingController.java @@ -116,6 +116,20 @@ public String untagFamily(@RequestBody String comingReq) { return response.toString(); } + @Operation(summary = "Get family tagging details by beneficiary ID") + @PostMapping(value = { "/getBenFamilyDetails" }, consumes = "application/json", produces = "application/json") + public String getFamilyDetailsByBeneficiaryId(@RequestBody String comingReq) { + OutputResponse response = new OutputResponse(); + try { + String s = familyTagService.getFamilyDetailsByBeneficiaryId(comingReq); + response.setResponse(s); + } catch (Exception e) { + logger.error("Error in fetching family details by beneficiary ID : " + e); + response.setError(5000, "Error in fetching family details by beneficiary ID : " + e.getLocalizedMessage()); + } + return response.toString(); + } + @Operation(summary = "Edit beneficiary family details") @PostMapping(value = { "/editFamilyTagging" }, consumes = "application/json", produces = "application/json") public String editFamilyDetails(@RequestBody String comingReq) { diff --git a/src/main/java/com/iemr/common/identity/repo/BenContactRepo.java b/src/main/java/com/iemr/common/identity/repo/BenContactRepo.java index fdbda216..02334684 100644 --- a/src/main/java/com/iemr/common/identity/repo/BenContactRepo.java +++ b/src/main/java/com/iemr/common/identity/repo/BenContactRepo.java @@ -49,8 +49,12 @@ public interface BenContactRepo extends CrudRepository findByPreferredSMSPhoneNumOrderByBenContactsIDAsc(String smsPhoneNum); - @Query("select c from MBeneficiarycontact c where c.preferredPhoneNum = :phoneNum ") - List findByAnyPhoneNum(@Param("phoneNum") String phoneNum); + // @Query("select c from MBeneficiarycontact c where c.preferredPhoneNum = :phoneNum ") + // List findByAnyPhoneNum(@Param("phoneNum") String phoneNum); + + @Query("select c from MBeneficiarycontact c where c.preferredPhoneNum IN :variants") + List findByAnyPhoneNum(@Param("variants") List variants); + @Query("select c from MBeneficiarycontact c where c.preferredPhoneNum = :phoneNum or c.phoneNum1 = :phoneNum " @@ -70,4 +74,6 @@ public interface BenContactRepo extends CrudRepository findTop10000ByProvisionedAndReserved(Boolean isProvisioned,Boolean isReserved); + /** + * Atomically selects and locks the next available registration ID row. + * SKIP LOCKED ensures concurrent servers each get a distinct row without blocking each other, + * eliminating duplicate BenRegId assignments when multiple app instances share the same database. + */ + @Transactional + @Query(value = "SELECT * FROM m_beneficiaryregidmapping WHERE Provisioned = false AND Reserved = false ORDER BY BenRegId ASC LIMIT 1 FOR UPDATE SKIP LOCKED", nativeQuery = true) + MBeneficiaryregidmapping findAndLockNextAvailable(); + } diff --git a/src/main/java/com/iemr/common/identity/repo/familyTag/FamilyTagRepo.java b/src/main/java/com/iemr/common/identity/repo/familyTag/FamilyTagRepo.java index 4451fd37..88d05388 100644 --- a/src/main/java/com/iemr/common/identity/repo/familyTag/FamilyTagRepo.java +++ b/src/main/java/com/iemr/common/identity/repo/familyTag/FamilyTagRepo.java @@ -42,12 +42,10 @@ public interface FamilyTagRepo extends CrudRepository { public int untagFamily(@Param("benFamilyTagId") List benFamilyTagId,@Param("modifiedBy") String modifiedBy); - @Query("SELECT obj FROM BenFamilyMapping obj WHERE obj.familyName =:familyName AND obj.villageId =:villageId AND (obj.noOfmembers is not null " - + " AND obj.noOfmembers >0)") + @Query("SELECT obj FROM BenFamilyMapping obj WHERE obj.familyName LIKE CONCAT(:familyName, '%') AND (:villageId IS NULL OR obj.villageId =:villageId) AND (obj.deleted IS NULL OR obj.deleted = false)") List searchFamily(@Param("familyName") String familyName,@Param("villageId") Integer villageId); - - @Query("SELECT obj FROM BenFamilyMapping obj WHERE obj.familyName =:familyName AND obj.villageId =:villageId AND obj.familyId =:familyId AND (obj.noOfmembers is not null " - + " AND obj.noOfmembers >0)") + + @Query("SELECT obj FROM BenFamilyMapping obj WHERE obj.familyName LIKE CONCAT(:familyName, '%') AND (:villageId IS NULL OR obj.villageId =:villageId) AND obj.familyId =:familyId AND (obj.deleted IS NULL OR obj.deleted = false)") List searchFamilyWithFamilyId(@Param("familyName") String familyName,@Param("villageId") Integer villageId,@Param("familyId") String familyId); @Query("SELECT obj FROM BenFamilyMapping obj WHERE obj.familyId =:familyId") diff --git a/src/main/java/com/iemr/common/identity/service/BenRegIdClaimService.java b/src/main/java/com/iemr/common/identity/service/BenRegIdClaimService.java new file mode 100644 index 00000000..ba2c32eb --- /dev/null +++ b/src/main/java/com/iemr/common/identity/service/BenRegIdClaimService.java @@ -0,0 +1,89 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.common.identity.service; + +import java.sql.Timestamp; + +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.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.common.identity.domain.MBeneficiaryregidmapping; +import com.iemr.common.identity.repo.BenRegIdMappingRepo; + +/** + * Handles atomic beneficiary registration ID claiming. + * + * Uses SELECT ... FOR UPDATE SKIP LOCKED so that each application instance + * obtains a distinct, exclusive row. Multiple servers sharing the same database + * will never receive the same BenRegId, preventing + * SQLIntegrityConstraintViolationException duplicate-key errors that occurred + * with the previous in-memory ArrayDeque queue approach. + * + * REQUIRES_NEW propagation ensures the SELECT + UPDATE happens in its own + * short-lived transaction, releasing the row lock immediately after the ID is + * marked reserved — keeping lock contention to a minimum. + */ +@Service +public class BenRegIdClaimService { + + private static final Logger logger = LoggerFactory.getLogger(BenRegIdClaimService.class); + + @Autowired + private BenRegIdMappingRepo regIdRepo; + + /** + * Atomically claims the next available registration ID. + * + *
    + *
  1. Opens a brand-new transaction (REQUIRES_NEW).
  2. + *
  3. Executes SELECT … FOR UPDATE SKIP LOCKED to lock exactly one row. + * Concurrent callers on other servers/threads skip the locked row and + * get the next one — no two callers ever see the same row.
  4. + *
  5. Marks the row {@code reserved = true} and flushes it within the same + * transaction so the change is visible to other connections the moment + * this method returns.
  6. + *
+ * + * @return the reserved {@link MBeneficiaryregidmapping} with {@code reserved=true} + * @throws IllegalStateException if the ID pool is exhausted + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public MBeneficiaryregidmapping claimNextAvailableRegId() { + MBeneficiaryregidmapping regMap = regIdRepo.findAndLockNextAvailable(); + if (regMap == null) { + throw new IllegalStateException( + "No available registration IDs in the pool. " + + "Please contact the system administrator to import more IDs."); + } + if (regMap.getCreatedDate() == null) { + regMap.setCreatedDate(new Timestamp(System.currentTimeMillis())); + } + regMap.setReserved(true); + regMap = regIdRepo.save(regMap); + logger.info("BenRegIdClaimService: claimed BenRegId={}", regMap.getBenRegId()); + return regMap; + } +} diff --git a/src/main/java/com/iemr/common/identity/service/IdentityService.java b/src/main/java/com/iemr/common/identity/service/IdentityService.java index 08b9fbbe..afb193e3 100644 --- a/src/main/java/com/iemr/common/identity/service/IdentityService.java +++ b/src/main/java/com/iemr/common/identity/service/IdentityService.java @@ -25,8 +25,8 @@ import java.math.BigInteger; import java.sql.Timestamp; import java.text.SimpleDateFormat; -import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -154,6 +154,8 @@ private JdbcTemplate getJdbcTemplate() { @Autowired BenRegIdMappingRepo regIdRepo; @Autowired + private BenRegIdClaimService benRegIdClaimService; + @Autowired BenServiceMappingRepo serviceMapRepo; @Autowired MBeneficiaryAccountRepo accountRepo; @@ -555,7 +557,15 @@ public List getBeneficiariesByPhoneNum(String phoneNum) List list = new ArrayList<>(); try { - List benContact = contactRepo.findByAnyPhoneNum(phoneNum); + // List benContact = contactRepo.findByAnyPhoneNum(phoneNum); + + String clean = phoneNum.trim(); + if (clean.startsWith("+91")) clean = clean.substring(3); + else if (clean.startsWith("91") && clean.length() == 12) clean = clean.substring(2); + else if (clean.startsWith("0") && clean.length() == 11) clean = clean.substring(1); + + List variants = Arrays.asList(clean, "0" + clean, "91" + clean, "+91" + clean); + List benContact = contactRepo.findByAnyPhoneNum(variants); logger.info(benContact.size() + " contacts found for phone number " + phoneNum); @@ -1038,6 +1048,18 @@ public void editIdentity(IdentityEditDTO identity) throws MissingMandatoryFields if (benDetails.getOther() != null) { mbDetl.setOther(benDetails.getOther()); } + if (mbDetl.getOccupationId() == null && benDetails.getOccupationId() != null) { + mbDetl.setOccupationId(benDetails.getOccupationId()); + } + if (mbDetl.getOccupation() == null && benDetails.getOccupation() != null) { + mbDetl.setOccupation(benDetails.getOccupation()); + } + if (mbDetl.getEducationId() == null && benDetails.getEducationId() != null) { + mbDetl.setEducationId(benDetails.getEducationId()); + } + if (mbDetl.getEducation() == null && benDetails.getEducation() != null) { + mbDetl.setEducation(benDetails.getEducation()); + } // Extract and set extra fields // String identityJson = new Gson().toJson(json); @@ -1337,6 +1359,8 @@ private MBeneficiarydetail convertIdentityEditDTOToMBeneficiarydetail(IdentityEd if (dto.getOtherFields() != null) { beneficiarydetail.setOtherFields(dto.getOtherFields()); } + beneficiarydetail.setSexualOrientationID(dto.getSexualOrientationID()); + beneficiarydetail.setSexualOrientationType(dto.getSexualOrientationType()); return beneficiarydetail; } @@ -1345,34 +1369,13 @@ private MBeneficiarydetail convertIdentityEditDTOToMBeneficiarydetail(IdentityEd * @param identity * @return */ - ArrayDeque queue = new ArrayDeque<>(); - public BeneficiaryCreateResp createIdentity(IdentityDTO identity) { logger.info("IdentityService.createIdentity - start"); - List list = null; - MBeneficiaryregidmapping regMap = null; - synchronized (queue) { - if (queue.isEmpty()) { - logger.info("fetching 10000 rows"); - list = regIdRepo.findTop10000ByProvisionedAndReserved(false, false); - logger.info("Adding SynchronousQueue start-- "); - for (MBeneficiaryregidmapping map : list) { - queue.add(map); - } - logger.info("Adding SynchronousQueue end-- "); - } - regMap = queue.removeFirst(); - } - regMap.setReserved(true); - if (regMap.getCreatedDate() == null) { - SimpleDateFormat sdf = new SimpleDateFormat(CREATED_DATE_FORMAT); - String dateToStoreInDataBase = sdf.format(new Date()); - Timestamp ts = Timestamp.valueOf(dateToStoreInDataBase); - regMap.setCreatedDate(ts); - } - - regIdRepo.save(regMap); + // Atomically claim the next available ID using SELECT … FOR UPDATE SKIP LOCKED. + // This is safe across multiple app servers sharing the same database — each server + // locks and reserves a distinct row, so duplicate BenRegId inserts cannot occur. + MBeneficiaryregidmapping regMap = benRegIdClaimService.claimNextAvailableRegId(); regMap.setProvisioned(true); @@ -1665,6 +1668,9 @@ private String cleanPhoneNumber(String phoneNumber) { } else if (cleaned.startsWith("91") && cleaned.length() == 12) { // Handle case where + is already removed but 91 remains cleaned = cleaned.substring(2); + } else if (cleaned.startsWith("0") && cleaned.length() == 11) { + // Handle case where number starts with 0 and is 11 digits long + cleaned = cleaned.substring(1); } return cleaned.trim(); diff --git a/src/main/java/com/iemr/common/identity/service/familyTagging/FamilyTagService.java b/src/main/java/com/iemr/common/identity/service/familyTagging/FamilyTagService.java index 85e8cfba..5bf22b36 100644 --- a/src/main/java/com/iemr/common/identity/service/familyTagging/FamilyTagService.java +++ b/src/main/java/com/iemr/common/identity/service/familyTagging/FamilyTagService.java @@ -36,4 +36,6 @@ public interface FamilyTagService { public String searchFamily(String request) throws IEMRException; public String editFamilyDetails(String request) throws IEMRException; + + public String getFamilyDetailsByBeneficiaryId(String request) throws IEMRException; } diff --git a/src/main/java/com/iemr/common/identity/service/familyTagging/FamilyTagServiceImpl.java b/src/main/java/com/iemr/common/identity/service/familyTagging/FamilyTagServiceImpl.java index 2a60571b..f44eecf8 100644 --- a/src/main/java/com/iemr/common/identity/service/familyTagging/FamilyTagServiceImpl.java +++ b/src/main/java/com/iemr/common/identity/service/familyTagging/FamilyTagServiceImpl.java @@ -37,6 +37,7 @@ import com.google.gson.JsonParser; import com.iemr.common.identity.data.familyTagging.BenFamilyMapping; import com.iemr.common.identity.data.familyTagging.FamilyMembers; +import com.iemr.common.identity.data.familyTagging.FamilySearchResponse; import com.iemr.common.identity.domain.MBeneficiarydetail; import com.iemr.common.identity.domain.MBeneficiarymapping; import com.iemr.common.identity.exception.IEMRException; @@ -264,10 +265,49 @@ public String getFamilyDetails(String request) throws IEMRException { throw new IEMRException("Error while fetching family member details :" + e.getLocalizedMessage()); } } + @Override + public String getFamilyDetailsByBeneficiaryId(String request) throws IEMRException { + try { + BenFamilyMapping reqObj = InputMapper.gson().fromJson(request, BenFamilyMapping.class); + if (reqObj.getBeneficiaryRegId() == null) + throw new IEMRException("beneficiaryRegId is required"); + + MBeneficiarymapping mapping = benMappingRepo + .getBenDetailsId(BigInteger.valueOf(reqObj.getBeneficiaryRegId())); + if (mapping == null || mapping.getBenDetailsId() == null) + throw new IEMRException("Beneficiary not found"); + + List benDetails = benDetailRepo + .findByBeneficiaryDetailsIdOrderByBeneficiaryDetailsIdAsc(mapping.getBenDetailsId()); + if (benDetails == null || benDetails.isEmpty() || benDetails.get(0).getFamilyId() == null) + return "No family tagged to this beneficiary"; + + String familyId = benDetails.get(0).getFamilyId(); + + BenFamilyMapping familyMaster = familyTagRepo.searchFamilyByFamilyId(familyId); + List memberList = benDetailRepo.getFamilyDetails(familyId); + + FamilySearchResponse resp = new FamilySearchResponse(); + if (familyMaster != null) { + resp.setFamilyId(familyMaster.getFamilyId()); + resp.setFamilyName(familyMaster.getFamilyName()); + resp.setHeadOfTheFamily(familyMaster.getFamilyHeadName()); + resp.setNoOfMembers(familyMaster.getNoOfmembers()); + } + List memberResponseList = new ArrayList<>(); + addFamilyMembersToList(memberList, memberResponseList); + resp.setFamilyMembers(memberResponseList); + + return new Gson().toJson(resp); + } catch (Exception e) { + throw new IEMRException( + "Error while fetching family details by beneficiary ID : " + e.getLocalizedMessage()); + } + } + private void addFamilyMembersToList(List list, List responseList) { - StringBuilder name = new StringBuilder(""); for (MBeneficiarydetail obj : list) { - + StringBuilder name = new StringBuilder(""); FamilyMembers famObj = new FamilyMembers(); BigInteger benRegId = benMappingRepo.getBenRegId(obj.getBeneficiaryDetailsId(), obj.getVanID()); if (benRegId != null) diff --git a/src/main/java/com/iemr/common/identity/utils/JwtUserIdValidationFilter.java b/src/main/java/com/iemr/common/identity/utils/JwtUserIdValidationFilter.java index af81ea34..cf959aa6 100644 --- a/src/main/java/com/iemr/common/identity/utils/JwtUserIdValidationFilter.java +++ b/src/main/java/com/iemr/common/identity/utils/JwtUserIdValidationFilter.java @@ -43,10 +43,13 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo return; } String path = request.getRequestURI(); - logger.info("JwtUserIdValidationFilter invoked for path: {}", path); + + String servletPath = request.getServletPath(); + logger.info("JwtUserIdValidationFilter invoked for requestURI: {}, servletPath: {}", path, servletPath); // Skip JWT validation for public endpoints - if (path.equals("/health") || path.equals("/version")) { + if (servletPath.equals("/health") || servletPath.equals("/version") || + path.endsWith("/health") || path.endsWith("/version")) { logger.info("Public endpoint accessed: {} - skipping JWT validation", path); filterChain.doFilter(servletRequest, servletResponse); return;