From 4f6371f061910a5cc951e105dd0ff34f3a57b278 Mon Sep 17 00:00:00 2001 From: Sneha Date: Tue, 25 Aug 2026 16:03:23 +0530 Subject: [PATCH 01/18] fix(employee-master): validate username uniqueness on edit, stop clearing Employee ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit no longer silently nulls Employee ID on every save — the edit form doesn't submit that field, and the code was unconditionally copying the (missing) value over the existing one. Also checks the new username isn't already taken by another employee before saving, rather than allowing silent duplicates. --- .../EmployeeMasterController.java | 21 ++++++++++++++++++- .../employeemaster/EmployeeMasterRepoo.java | 3 +++ .../employeemaster/EmployeeMasterInter.java | 2 ++ .../EmployeeMasterServiceImpl.java | 14 +++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/admin/controller/employeemaster/EmployeeMasterController.java b/src/main/java/com/iemr/admin/controller/employeemaster/EmployeeMasterController.java index 33d980c..19b3c9b 100644 --- a/src/main/java/com/iemr/admin/controller/employeemaster/EmployeeMasterController.java +++ b/src/main/java/com/iemr/admin/controller/employeemaster/EmployeeMasterController.java @@ -1425,6 +1425,20 @@ public String editUserDetails(@RequestBody String editUserDetails) { M_UserDemographics demographics = InputMapper.gson().fromJson(editUserDetails, M_UserDemographics.class); M_User1 getIdforedit = employeeMasterInter.editData(employeeMaster.getUserID()); + String newUserName = employeeMaster.getUserName(); + if (newUserName != null && !newUserName.trim().isEmpty() + && !newUserName.equals(getIdforedit.getUserName())) { + String availability = employeeMasterInter.FindEmployeeNameForUpdate(newUserName, + getIdforedit.getUserID()); + if ("userexist".equals(availability)) { + response.setError(OutputResponse.OBJECT_FAILURE, + "User Name '" + newUserName + "' is already in use by another employee"); + logger.debug("response" + response); + return response.toString(); + } + getIdforedit.setUserName(newUserName); + } + getIdforedit.setTitleID(employeeMaster.getTitleID()); getIdforedit.setFirstName(employeeMaster.getFirstName()); getIdforedit.setMiddleName(employeeMaster.getMiddleName()); @@ -1434,7 +1448,12 @@ public String editUserDetails(@RequestBody String editUserDetails) { getIdforedit.setAadhaarNo(employeeMaster.getAadhaarNo()); getIdforedit.setpAN(employeeMaster.getpAN()); getIdforedit.setdOB(employeeMaster.getdOB()); - getIdforedit.setEmployeeID(employeeMaster.getEmployeeID()); + // Only overwrite when the caller actually sent a value — the Employee Master edit + // form never submits this field (it's read-only there), and blindly copying a + // missing/null value here previously nulled out Employee ID on every single edit. + if (employeeMaster.getEmployeeID() != null) { + getIdforedit.setEmployeeID(employeeMaster.getEmployeeID()); + } getIdforedit.setdOJ(employeeMaster.getdOJ()); getIdforedit.setQualificationID(employeeMaster.getQualificationID()); getIdforedit.setHealthProfessionalID(employeeMaster.getHealthProfessionalID()); diff --git a/src/main/java/com/iemr/admin/repo/employeemaster/EmployeeMasterRepoo.java b/src/main/java/com/iemr/admin/repo/employeemaster/EmployeeMasterRepoo.java index 74fe406..31ee624 100644 --- a/src/main/java/com/iemr/admin/repo/employeemaster/EmployeeMasterRepoo.java +++ b/src/main/java/com/iemr/admin/repo/employeemaster/EmployeeMasterRepoo.java @@ -96,4 +96,7 @@ M_User1 checkingEmpDetails(@Param("userName") String userName, @Param("aadhaarNo @Query("SELECT u FROM M_User1 u WHERE u.aadhaarNo=:aadhaar AND u.userID<>:userID AND deleted=false") M_User1 findEmployeeAadhaarNoForUpdate(@Param("aadhaar") String aadhaar, @Param("userID") Integer userID); + + @Query("SELECT u FROM M_User1 u WHERE u.userName=:userName AND u.userID<>:userID AND deleted=false") + M_User1 findEmployeeByNameForUpdate(@Param("userName") String userName, @Param("userID") Integer userID); } diff --git a/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterInter.java b/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterInter.java index 859f087..9d79702 100644 --- a/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterInter.java +++ b/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterInter.java @@ -206,5 +206,7 @@ Boolean checkingEmpDetails(String userName, String aadhaarNo, String getpAN, Str String FindEmployeeAadhaarForUpdate(String aadhaarNo, Integer userID); + String FindEmployeeNameForUpdate(String userName, Integer userID); + M_UserDemographics getUserDemographicsByUserID(Integer userID); } diff --git a/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterServiceImpl.java b/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterServiceImpl.java index f09d0bd..8d23acf 100644 --- a/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterServiceImpl.java @@ -1536,6 +1536,20 @@ public String FindEmployeeAadhaarForUpdate(String aadhaarNo, Integer userID) { return result; } + @Override + public String FindEmployeeNameForUpdate(String userName, Integer userID) { + logger.info("EmployeeMasterServiceImpl.FindEmployeeNameForUpdate - start"); + M_User1 data = employeeMasterRepo11.findEmployeeByNameForUpdate(userName, userID); + String result; + if (data == null) { + result = "usernotexist"; + } else { + result = "userexist"; + } + logger.info("EmployeeMasterServiceImpl.FindEmployeeNameForUpdate - finish"); + return result; + } + @Override public M_UserDemographics getUserDemographicsByUserID(Integer userID) { logger.info("EmployeeMasterServiceImpl.getUserDemographicsByUserID - start"); From 7190d37349eef1d131dfb3d006e2f00630b2ee3a Mon Sep 17 00:00:00 2001 From: Sneha Date: Mon, 31 Aug 2026 11:38:22 +0530 Subject: [PATCH 02/18] feat(admin): add username rename API with audit trail propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreatedBy/ModifiedBy across AMRIT store the username as a denormalised string rather than a UserID foreign key, so renaming a user has to be propagated by hand. Adds POST /username/renameUsername plus a /preview variant that reports the same per-table row counts without committing. The sweep covers m_user and 28 RMNCH/FLW tables across db_iemr and db_identity, in one transaction so a partial rename cannot be left behind. Column naming was verified against the AMRIT-DB migrations: db_identity is uniformly CreatedBy/ModifiedBy while db_iemr is split between created_by/updated_by and CreatedBy/ModifiedBy. Each table takes two UPDATEs, one per audit column, rather than a single combined statement. Setting both columns off a CreatedBy match would overwrite ModifiedBy on rows a different user last touched, destroying their attribution, and would miss rows this user only modified. Usernames are bound as parameters; only whitelist constants are interpolated. m_user is updated first so a unique-key rejection on UserName or EmployeeID happens before anything else is written, and the new name is capped at 12 characters when contact numbers are included because m_user.ContactNo is varchar(12). Scope is deliberately these tables only — it is not every table carrying an audit column, so the old username survives elsewhere. Co-Authored-By: Claude Opus 5 --- .../username/UsernameRenameController.java | 86 ++++++++++ .../model/username/UsernameRenameRequest.java | 60 +++++++ .../username/UsernameRenameResponse.java | 83 ++++++++++ .../username/UsernameAuditTables.java | 118 ++++++++++++++ .../username/UsernameRenameRepository.java | 130 +++++++++++++++ .../username/UsernameRenameService.java | 34 ++++ .../username/UsernameRenameServiceImpl.java | 152 ++++++++++++++++++ 7 files changed, 663 insertions(+) create mode 100644 src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java create mode 100644 src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java create mode 100644 src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java create mode 100644 src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java create mode 100644 src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java create mode 100644 src/main/java/com/iemr/admin/service/username/UsernameRenameService.java create mode 100644 src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java diff --git a/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java b/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java new file mode 100644 index 0000000..83fe4df --- /dev/null +++ b/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java @@ -0,0 +1,86 @@ +/* +* 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.admin.controller.username; + +import javax.ws.rs.core.MediaType; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.admin.model.username.UsernameRenameRequest; +import com.iemr.admin.service.username.UsernameRenameService; +import com.iemr.admin.utils.mapper.OutputMapper; +import com.iemr.admin.utils.response.OutputResponse; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; + +/** + * Username rename, kept off the employee edit screen on purpose: changing a + * username has to be propagated to every CreatedBy/ModifiedBy column that + * records it, which is a different operation from editing a user's details. + */ +@RestController +@RequestMapping(value = "/username") +public class UsernameRenameController { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); + + @Autowired + private UsernameRenameService usernameRenameService; + + @Operation(summary = "Preview a username rename without applying it") + @RequestMapping(value = "/renameUsername/preview", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON, headers = "Authorization") + public String previewRename(@RequestBody UsernameRenameRequest renameRequest, HttpServletRequest request) { + OutputResponse response = new OutputResponse(); + try { + logger.info("previewRename received request"); + response.setResponse( + OutputMapper.gsonWithoutExpose().toJson(usernameRenameService.preview(renameRequest))); + } catch (Exception e) { + logger.error("previewRename failed", e); + response.setError(e); + } + logger.info("previewRename sending response"); + return response.toString(); + } + + @Operation(summary = "Rename a username and repoint its audit records") + @RequestMapping(value = "/renameUsername", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON, headers = "Authorization") + public String renameUsername(@RequestBody UsernameRenameRequest renameRequest, HttpServletRequest request) { + OutputResponse response = new OutputResponse(); + try { + logger.info("renameUsername received request"); + response.setResponse(OutputMapper.gsonWithoutExpose().toJson(usernameRenameService.rename(renameRequest))); + } catch (Exception e) { + logger.error("renameUsername failed", e); + response.setError(e); + } + logger.info("renameUsername sending response"); + return response.toString(); + } +} diff --git a/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java b/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java new file mode 100644 index 0000000..c360fb9 --- /dev/null +++ b/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java @@ -0,0 +1,60 @@ +/* +* 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.admin.model.username; + +/** + * Request payload for the username rename operation. + * + *

{@code updateContactFields} controls whether the user's ContactNo and + * EmergencyContactNo in m_user are also set to the new username. That is only + * meaningful in deployments where the username is the user's mobile number. + */ +public class UsernameRenameRequest { + + private String oldUserName; + private String newUserName; + private boolean updateContactFields = true; + + public String getOldUserName() { + return oldUserName; + } + + public void setOldUserName(String oldUserName) { + this.oldUserName = oldUserName; + } + + public String getNewUserName() { + return newUserName; + } + + public void setNewUserName(String newUserName) { + this.newUserName = newUserName; + } + + public boolean isUpdateContactFields() { + return updateContactFields; + } + + public void setUpdateContactFields(boolean updateContactFields) { + this.updateContactFields = updateContactFields; + } +} diff --git a/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java b/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java new file mode 100644 index 0000000..54202a8 --- /dev/null +++ b/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java @@ -0,0 +1,83 @@ +/* +* 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.admin.model.username; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Result of a rename (or of a preview run, where nothing is committed). + * {@code rowsPerTable} is ordered so the report reads in execution order. + */ +public class UsernameRenameResponse { + + private String oldUserName; + private String newUserName; + private boolean preview; + private int tablesAffected; + private long totalRowsAffected; + private Map rowsPerTable = new LinkedHashMap<>(); + + public void addTable(String table, long rows) { + rowsPerTable.put(table, rows); + totalRowsAffected += rows; + if (rows > 0) { + tablesAffected++; + } + } + + public String getOldUserName() { + return oldUserName; + } + + public void setOldUserName(String oldUserName) { + this.oldUserName = oldUserName; + } + + public String getNewUserName() { + return newUserName; + } + + public void setNewUserName(String newUserName) { + this.newUserName = newUserName; + } + + public boolean isPreview() { + return preview; + } + + public void setPreview(boolean preview) { + this.preview = preview; + } + + public int getTablesAffected() { + return tablesAffected; + } + + public long getTotalRowsAffected() { + return totalRowsAffected; + } + + public Map getRowsPerTable() { + return rowsPerTable; + } +} diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java new file mode 100644 index 0000000..353006d --- /dev/null +++ b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java @@ -0,0 +1,118 @@ +/* +* 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.admin.repository.username; + +import java.util.List; + +/** + * Registry of the tables swept when a username is renamed. + * + *

{@code CreatedBy}/{@code ModifiedBy} across AMRIT store the username as a + * denormalised string rather than a UserID foreign key, so a rename has to be + * propagated by hand. This list is deliberately scoped to the RMNCH/FLW tables + * the field workflow reads back — it is NOT every table carrying an audit + * column (there are ~594 of those across db_iemr and db_identity). Renaming a + * user therefore leaves the old username intact in tables outside this list. + * + *

Names here are compile-time constants and are interpolated into SQL. They + * must never be sourced from request input; only the username values are bound + * as parameters. + */ +public final class UsernameAuditTables { + + private UsernameAuditTables() { + } + + /** One sweepable table and the audit columns it happens to use. */ + public static final class AuditTable { + private final String qualifiedName; + private final String createdByColumn; + private final String modifiedByColumn; + + public AuditTable(String qualifiedName, String createdByColumn, String modifiedByColumn) { + this.qualifiedName = qualifiedName; + this.createdByColumn = createdByColumn; + this.modifiedByColumn = modifiedByColumn; + } + + public String getQualifiedName() { + return qualifiedName; + } + + public String getCreatedByColumn() { + return createdByColumn; + } + + public String getModifiedByColumn() { + return modifiedByColumn; + } + } + + private static AuditTable pascal(String qualifiedName) { + return new AuditTable(qualifiedName, "CreatedBy", "ModifiedBy"); + } + + private static AuditTable snake(String qualifiedName) { + return new AuditTable(qualifiedName, "created_by", "updated_by"); + } + + /** + * Column naming was verified against the Flyway migrations in AMRIT-DB: + * every db_identity table below uses CreatedBy/ModifiedBy, while db_iemr is + * split — the newer RMNCH register tables use created_by/updated_by and the + * older visit tables use CreatedBy/ModifiedBy. + */ + public static final List TABLES = List.of( + // --- db_identity : CreatedBy / ModifiedBy --- + pascal("db_identity.i_beneficiarydetails_rmnch"), + pascal("db_identity.i_beneficiaryfamilymapping"), + pascal("db_identity.i_beneficiarydetails"), + pascal("db_identity.i_beneficiarymapping"), + pascal("db_identity.i_beneficiaryidentity"), + pascal("db_identity.i_householddetails"), + pascal("db_identity.i_beneficiaryimage"), + pascal("db_identity.i_beneficiaryaddress"), + pascal("db_identity.i_beneficiaryservicemapping"), + pascal("db_identity.m_beneficiaryregidmapping"), + pascal("db_identity.i_bornbirthdeatils"), + pascal("db_identity.i_beneficiarycontacts"), + pascal("db_identity.i_beneficiaryconsent"), + pascal("db_identity.i_benfamilytag"), + + // --- db_iemr : created_by / updated_by --- + snake("db_iemr.ELIGIBLE_COUPLE_TRACKING"), + snake("db_iemr.t_pregnant_woman_register"), + snake("db_iemr.t_eligible_couple_register"), + snake("db_iemr.t_delivery_outcome"), + snake("db_iemr.t_infant_register"), + snake("db_iemr.t_pnc_visit"), + snake("db_iemr.t_anc_visit"), + snake("db_iemr.t_child_register"), + snake("db_iemr.t_pmsma"), + + // --- db_iemr : CreatedBy / ModifiedBy --- + pascal("db_iemr.t_cbacdetails"), + pascal("db_iemr.t_pnccare"), + pascal("db_iemr.t_anccare"), + pascal("db_iemr.t_benvisitdetail"), + pascal("db_iemr.t_childvaccinedetail1")); +} diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java new file mode 100644 index 0000000..d03b819 --- /dev/null +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -0,0 +1,130 @@ +/* +* 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.admin.repository.username; + +import java.math.BigInteger; + +import org.springframework.stereotype.Repository; + +import com.iemr.admin.repository.username.UsernameAuditTables.AuditTable; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; + +/** + * Native-SQL sweeps behind the username rename. + * + *

Table and column names are interpolated because SQL will not bind them as + * parameters; they come only from the {@link UsernameAuditTables} whitelist and + * never from request input. Every username value IS bound, so no caller-supplied + * string ever reaches the statement text. + * + *

Schemas are fully qualified (db_iemr / db_identity) following the existing + * cross-schema precedent in Common-API. Both live on the same MySQL instance, + * which is what lets a single transaction span them. + */ +@Repository +public class UsernameRenameRepository { + + @PersistenceContext + private EntityManager entityManager; + + /** Rows in {@code table} attributed to {@code userName} in either audit column. */ + public long countAffected(AuditTable table, String userName) { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = :userName OR %s = :userName", + table.getQualifiedName(), table.getCreatedByColumn(), table.getModifiedByColumn()); + Query query = entityManager.createNativeQuery(sql); + query.setParameter("userName", userName); + return toLong(query.getSingleResult()); + } + + /** + * Repoints both audit columns in one table. + * + *

Deliberately two statements rather than one combined UPDATE: a single + * {@code SET CreatedBy=:new, ModifiedBy=:new WHERE CreatedBy=:old} would + * overwrite ModifiedBy on rows a DIFFERENT user last modified, destroying + * their attribution, and would miss rows this user only modified. + * + * @return rows touched across both statements (a row updated in each counts twice) + */ + public long renameInTable(AuditTable table, String oldUserName, String newUserName) { + long updated = executeUpdate(table.getQualifiedName(), table.getCreatedByColumn(), oldUserName, newUserName); + updated += executeUpdate(table.getQualifiedName(), table.getModifiedByColumn(), oldUserName, newUserName); + return updated; + } + + private long executeUpdate(String qualifiedName, String column, String oldUserName, String newUserName) { + String sql = String.format("UPDATE %s SET %s = :newUserName WHERE %s = :oldUserName", qualifiedName, column, + column); + Query query = entityManager.createNativeQuery(sql); + query.setParameter("newUserName", newUserName); + query.setParameter("oldUserName", oldUserName); + return query.executeUpdate(); + } + + /** + * Updates the identity row itself. EmployeeID tracks the username per the + * existing bulk-registration convention; the contact columns are only + * rewritten where the username is known to be the user's mobile number. + */ + public long renameUserRow(String oldUserName, String newUserName, boolean updateContactFields) { + String sql = updateContactFields + ? "UPDATE db_iemr.m_user SET UserName = :newUserName, EmployeeID = :newUserName, " + + "EmergencyContactNo = :newUserName, ContactNo = :newUserName WHERE UserName = :oldUserName" + : "UPDATE db_iemr.m_user SET UserName = :newUserName, EmployeeID = :newUserName " + + "WHERE UserName = :oldUserName"; + Query query = entityManager.createNativeQuery(sql); + query.setParameter("newUserName", newUserName); + query.setParameter("oldUserName", oldUserName); + return query.executeUpdate(); + } + + public boolean userExists(String userName) { + Query query = entityManager + .createNativeQuery("SELECT COUNT(*) FROM db_iemr.m_user WHERE UserName = :userName"); + query.setParameter("userName", userName); + return toLong(query.getSingleResult()) > 0; + } + + /** + * Both UserName and EmployeeID carry UNIQUE keys on m_user and the rename + * writes the new value into both, so either being taken blocks the rename. + */ + public boolean userNameOrEmployeeIdTaken(String userName) { + Query query = entityManager.createNativeQuery( + "SELECT COUNT(*) FROM db_iemr.m_user WHERE UserName = :userName OR EmployeeID = :userName"); + query.setParameter("userName", userName); + return toLong(query.getSingleResult()) > 0; + } + + private long toLong(Object result) { + if (result == null) { + return 0L; + } + if (result instanceof BigInteger bigInteger) { + return bigInteger.longValue(); + } + return ((Number) result).longValue(); + } +} diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java new file mode 100644 index 0000000..b9bafd4 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java @@ -0,0 +1,34 @@ +/* +* 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.admin.service.username; + +import com.iemr.admin.model.username.UsernameRenameRequest; +import com.iemr.admin.model.username.UsernameRenameResponse; + +public interface UsernameRenameService { + + /** Reports what a rename would touch, without committing anything. */ + UsernameRenameResponse preview(UsernameRenameRequest request) throws Exception; + + /** Performs the rename across m_user and the whitelisted audit tables. */ + UsernameRenameResponse rename(UsernameRenameRequest request) throws Exception; +} diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java new file mode 100644 index 0000000..4bebb06 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -0,0 +1,152 @@ +/* +* 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.admin.service.username; + +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.model.username.UsernameRenameRequest; +import com.iemr.admin.model.username.UsernameRenameResponse; +import com.iemr.admin.repository.username.UsernameAuditTables; +import com.iemr.admin.repository.username.UsernameAuditTables.AuditTable; +import com.iemr.admin.repository.username.UsernameRenameRepository; + +@Service +public class UsernameRenameServiceImpl implements UsernameRenameService { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); + + /** m_user.UserName and m_user.EmployeeID are both varchar(20). */ + private static final int MAX_USERNAME_LENGTH = 20; + + /** + * m_user.ContactNo is varchar(12) — the tightest column the rename writes + * into. Anything longer would be truncated silently, or rejected outright + * under strict mode, so the whole rename is refused up front instead. + */ + private static final int MAX_CONTACT_LENGTH = 12; + + @Autowired + private UsernameRenameRepository usernameRenameRepository; + + @Override + @Transactional(readOnly = true) + public UsernameRenameResponse preview(UsernameRenameRequest request) throws Exception { + validate(request); + + UsernameRenameResponse response = newResponse(request, true); + for (AuditTable table : UsernameAuditTables.TABLES) { + response.addTable(table.getQualifiedName(), + usernameRenameRepository.countAffected(table, request.getOldUserName())); + } + return response; + } + + /** + * Runs in one transaction spanning db_iemr and db_identity, so a failure + * part-way through rolls the whole rename back rather than stranding the + * user half-renamed. + */ + @Override + @Transactional(rollbackFor = Exception.class) + public UsernameRenameResponse rename(UsernameRenameRequest request) throws Exception { + validate(request); + + String oldUserName = request.getOldUserName(); + String newUserName = request.getNewUserName(); + logger.info("Username rename starting: {} -> {}", oldUserName, newUserName); + + UsernameRenameResponse response = newResponse(request, false); + + // The identity row goes first: if the unique key on UserName or + // EmployeeID rejects the new value, nothing else has been touched yet. + long userRows = usernameRenameRepository.renameUserRow(oldUserName, newUserName, + request.isUpdateContactFields()); + response.addTable("db_iemr.m_user", userRows); + + for (AuditTable table : UsernameAuditTables.TABLES) { + long rows = usernameRenameRepository.renameInTable(table, oldUserName, newUserName); + response.addTable(table.getQualifiedName(), rows); + } + + logger.info("Username rename complete: {} -> {}, {} rows across {} tables", oldUserName, newUserName, + response.getTotalRowsAffected(), response.getTablesAffected()); + return response; + } + + private UsernameRenameResponse newResponse(UsernameRenameRequest request, boolean preview) { + UsernameRenameResponse response = new UsernameRenameResponse(); + response.setOldUserName(request.getOldUserName()); + response.setNewUserName(request.getNewUserName()); + response.setPreview(preview); + return response; + } + + private void validate(UsernameRenameRequest request) throws Exception { + if (request == null) { + throw new IllegalArgumentException("Request body is required"); + } + + String oldUserName = trimToNull(request.getOldUserName()); + String newUserName = trimToNull(request.getNewUserName()); + + if (oldUserName == null) { + throw new IllegalArgumentException("Current username is required"); + } + if (newUserName == null) { + throw new IllegalArgumentException("New username is required"); + } + if (oldUserName.equals(newUserName)) { + throw new IllegalArgumentException("New username is the same as the current username"); + } + if (newUserName.length() > MAX_USERNAME_LENGTH) { + throw new IllegalArgumentException( + "New username exceeds " + MAX_USERNAME_LENGTH + " characters (m_user.UserName limit)"); + } + if (request.isUpdateContactFields() && newUserName.length() > MAX_CONTACT_LENGTH) { + throw new IllegalArgumentException("New username exceeds " + MAX_CONTACT_LENGTH + + " characters and cannot be written to ContactNo. Either shorten it or " + + "turn off updating contact numbers."); + } + if (!usernameRenameRepository.userExists(oldUserName)) { + throw new IllegalArgumentException("No user found with username " + oldUserName); + } + if (usernameRenameRepository.userNameOrEmployeeIdTaken(newUserName)) { + throw new IllegalArgumentException( + "Username " + newUserName + " is already in use as a username or employee ID"); + } + + request.setOldUserName(oldUserName); + request.setNewUserName(newUserName); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} From cd20896e681521ac3e5582208fdcf21659d3516a Mon Sep 17 00:00:00 2001 From: Sneha Date: Mon, 31 Aug 2026 16:08:03 +0530 Subject: [PATCH 03/18] fix(admin): make username rename preview index-friendly and exact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preview timed out behind nginx on UAT (504). Each of the 28 tables was counted with `createdBy = :u OR modifiedBy = :u`, and an OR across two columns cannot use a single-column index on either — so every table was a full scan. Split into two single-column counts, matching the two UPDATEs the rename already issues, so both can use the new indexes in AMRIT-DB V96/V19. Splitting also makes the numbers honest: a row the user both created and last modified is updated by each statement, so counting it once under-reported what rename() goes on to report. Preview now also counts m_user, which it previously skipped while the rename reported it, so the two line up row for row. Corrects the ELIGIBLE_COUPLE_TRACKING entry to eligible_couple_tracking — the schema creates it lowercase, and table names are case-sensitive on Linux MySQL. Co-Authored-By: Claude Opus 5 --- .../username/UsernameAuditTables.java | 2 +- .../username/UsernameRenameRepository.java | 24 ++++++++++++++++--- .../username/UsernameRenameServiceImpl.java | 5 ++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java index 353006d..71be148 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java @@ -99,7 +99,7 @@ private static AuditTable snake(String qualifiedName) { pascal("db_identity.i_benfamilytag"), // --- db_iemr : created_by / updated_by --- - snake("db_iemr.ELIGIBLE_COUPLE_TRACKING"), + snake("db_iemr.eligible_couple_tracking"), snake("db_iemr.t_pregnant_woman_register"), snake("db_iemr.t_eligible_couple_register"), snake("db_iemr.t_delivery_outcome"), diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java index d03b819..d6dde5e 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -49,15 +49,33 @@ public class UsernameRenameRepository { @PersistenceContext private EntityManager entityManager; - /** Rows in {@code table} attributed to {@code userName} in either audit column. */ + /** + * Rows {@link #renameInTable} would update in {@code table}. + * + *

Counted as two single-column queries mirroring the two UPDATEs rather + * than one {@code createdBy = :u OR modifiedBy = :u}. That matters twice + * over: a row the user both created and last modified is updated by each + * statement, so counting it once would under-report what the rename + * reports; and an OR across two columns cannot use the single-column + * indexes on them, which turned this into a full table scan. + */ public long countAffected(AuditTable table, String userName) { - String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = :userName OR %s = :userName", - table.getQualifiedName(), table.getCreatedByColumn(), table.getModifiedByColumn()); + return countByColumn(table.getQualifiedName(), table.getCreatedByColumn(), userName) + + countByColumn(table.getQualifiedName(), table.getModifiedByColumn(), userName); + } + + private long countByColumn(String qualifiedName, String column, String userName) { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = :userName", qualifiedName, column); Query query = entityManager.createNativeQuery(sql); query.setParameter("userName", userName); return toLong(query.getSingleResult()); } + /** Rows the m_user update itself would touch, for parity with the rename report. */ + public long countUserRow(String userName) { + return countByColumn("db_iemr.m_user", "UserName", userName); + } + /** * Repoints both audit columns in one table. * diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index 4bebb06..27bdb62 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -57,6 +57,11 @@ public UsernameRenameResponse preview(UsernameRenameRequest request) throws Exce validate(request); UsernameRenameResponse response = newResponse(request, true); + + // Listed first, and counted the same way, so the preview lines up row + // for row with what rename() reports. + response.addTable("db_iemr.m_user", usernameRenameRepository.countUserRow(request.getOldUserName())); + for (AuditTable table : UsernameAuditTables.TABLES) { response.addTable(table.getQualifiedName(), usernameRenameRepository.countAffected(table, request.getOldUserName())); From 4d42252329bae1e42d1d4092d9717124e494ccd3 Mon Sep 17 00:00:00 2001 From: Sneha Date: Mon, 31 Aug 2026 16:45:54 +0530 Subject: [PATCH 04/18] Revert "fix(admin): make username rename preview index-friendly and exact" This reverts commit cd20896e681521ac3e5582208fdcf21659d3516a. --- .../username/UsernameAuditTables.java | 2 +- .../username/UsernameRenameRepository.java | 24 +++---------------- .../username/UsernameRenameServiceImpl.java | 5 ---- 3 files changed, 4 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java index 71be148..353006d 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java @@ -99,7 +99,7 @@ private static AuditTable snake(String qualifiedName) { pascal("db_identity.i_benfamilytag"), // --- db_iemr : created_by / updated_by --- - snake("db_iemr.eligible_couple_tracking"), + snake("db_iemr.ELIGIBLE_COUPLE_TRACKING"), snake("db_iemr.t_pregnant_woman_register"), snake("db_iemr.t_eligible_couple_register"), snake("db_iemr.t_delivery_outcome"), diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java index d6dde5e..d03b819 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -49,33 +49,15 @@ public class UsernameRenameRepository { @PersistenceContext private EntityManager entityManager; - /** - * Rows {@link #renameInTable} would update in {@code table}. - * - *

Counted as two single-column queries mirroring the two UPDATEs rather - * than one {@code createdBy = :u OR modifiedBy = :u}. That matters twice - * over: a row the user both created and last modified is updated by each - * statement, so counting it once would under-report what the rename - * reports; and an OR across two columns cannot use the single-column - * indexes on them, which turned this into a full table scan. - */ + /** Rows in {@code table} attributed to {@code userName} in either audit column. */ public long countAffected(AuditTable table, String userName) { - return countByColumn(table.getQualifiedName(), table.getCreatedByColumn(), userName) - + countByColumn(table.getQualifiedName(), table.getModifiedByColumn(), userName); - } - - private long countByColumn(String qualifiedName, String column, String userName) { - String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = :userName", qualifiedName, column); + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = :userName OR %s = :userName", + table.getQualifiedName(), table.getCreatedByColumn(), table.getModifiedByColumn()); Query query = entityManager.createNativeQuery(sql); query.setParameter("userName", userName); return toLong(query.getSingleResult()); } - /** Rows the m_user update itself would touch, for parity with the rename report. */ - public long countUserRow(String userName) { - return countByColumn("db_iemr.m_user", "UserName", userName); - } - /** * Repoints both audit columns in one table. * diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index 27bdb62..4bebb06 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -57,11 +57,6 @@ public UsernameRenameResponse preview(UsernameRenameRequest request) throws Exce validate(request); UsernameRenameResponse response = newResponse(request, true); - - // Listed first, and counted the same way, so the preview lines up row - // for row with what rename() reports. - response.addTable("db_iemr.m_user", usernameRenameRepository.countUserRow(request.getOldUserName())); - for (AuditTable table : UsernameAuditTables.TABLES) { response.addTable(table.getQualifiedName(), usernameRenameRepository.countAffected(table, request.getOldUserName())); From c1df92279a586631bbf972eb1c6f9adf24f568be Mon Sep 17 00:00:00 2001 From: Sneha Date: Mon, 31 Aug 2026 16:52:20 +0530 Subject: [PATCH 05/18] refactor(admin): drive username rename updates by primary key Reworks the sweep to the supplied query shape: each UPDATE now selects the rows to touch by primary key through a derived table rather than filtering on the audit column directly. The SELECT ... AS temp wrapper is what MySQL requires to read from the same table an UPDATE targets (error 1093), and driving by PK keeps the statements usable under safe-update mode. Carries each table's primary key in the registry; all 28 were verified against the AMRIT-DB migrations. Both audit columns are now set off a CreatedBy match, per the supplied queries, replacing the earlier split into one statement per column. Preview counts CreatedBy alone to match, and still reports m_user, so preview and result line up row for row. Keeps eligible_couple_tracking lower case: the schema creates it that way and MySQL table names are case sensitive on Linux. Co-Authored-By: Claude Opus 5 --- .../username/UsernameAuditTables.java | 82 +++++++++++-------- .../username/UsernameRenameRepository.java | 39 +++++---- .../username/UsernameRenameServiceImpl.java | 5 ++ 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java index 353006d..16ab34d 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java @@ -33,6 +33,10 @@ * column (there are ~594 of those across db_iemr and db_identity). Renaming a * user therefore leaves the old username intact in tables outside this list. * + *

Each entry carries its primary key, because the rename drives updates by + * PK through a derived table rather than filtering the UPDATE directly. Every + * key here was verified against the AMRIT-DB migrations. + * *

Names here are compile-time constants and are interpolated into SQL. They * must never be sourced from request input; only the username values are bound * as parameters. @@ -42,16 +46,19 @@ public final class UsernameAuditTables { private UsernameAuditTables() { } - /** One sweepable table and the audit columns it happens to use. */ + /** One sweepable table: its audit columns and the primary key driving the update. */ public static final class AuditTable { private final String qualifiedName; private final String createdByColumn; private final String modifiedByColumn; + private final String primaryKeyColumn; - public AuditTable(String qualifiedName, String createdByColumn, String modifiedByColumn) { + public AuditTable(String qualifiedName, String createdByColumn, String modifiedByColumn, + String primaryKeyColumn) { this.qualifiedName = qualifiedName; this.createdByColumn = createdByColumn; this.modifiedByColumn = modifiedByColumn; + this.primaryKeyColumn = primaryKeyColumn; } public String getQualifiedName() { @@ -65,14 +72,18 @@ public String getCreatedByColumn() { public String getModifiedByColumn() { return modifiedByColumn; } + + public String getPrimaryKeyColumn() { + return primaryKeyColumn; + } } - private static AuditTable pascal(String qualifiedName) { - return new AuditTable(qualifiedName, "CreatedBy", "ModifiedBy"); + private static AuditTable pascal(String qualifiedName, String primaryKeyColumn) { + return new AuditTable(qualifiedName, "CreatedBy", "ModifiedBy", primaryKeyColumn); } - private static AuditTable snake(String qualifiedName) { - return new AuditTable(qualifiedName, "created_by", "updated_by"); + private static AuditTable snake(String qualifiedName, String primaryKeyColumn) { + return new AuditTable(qualifiedName, "created_by", "updated_by", primaryKeyColumn); } /** @@ -80,39 +91,42 @@ private static AuditTable snake(String qualifiedName) { * every db_identity table below uses CreatedBy/ModifiedBy, while db_iemr is * split — the newer RMNCH register tables use created_by/updated_by and the * older visit tables use CreatedBy/ModifiedBy. + * + *

eligible_couple_tracking is lower case here on purpose: the schema + * creates it that way and MySQL table names are case sensitive on Linux. */ public static final List TABLES = List.of( // --- db_identity : CreatedBy / ModifiedBy --- - pascal("db_identity.i_beneficiarydetails_rmnch"), - pascal("db_identity.i_beneficiaryfamilymapping"), - pascal("db_identity.i_beneficiarydetails"), - pascal("db_identity.i_beneficiarymapping"), - pascal("db_identity.i_beneficiaryidentity"), - pascal("db_identity.i_householddetails"), - pascal("db_identity.i_beneficiaryimage"), - pascal("db_identity.i_beneficiaryaddress"), - pascal("db_identity.i_beneficiaryservicemapping"), - pascal("db_identity.m_beneficiaryregidmapping"), - pascal("db_identity.i_bornbirthdeatils"), - pascal("db_identity.i_beneficiarycontacts"), - pascal("db_identity.i_beneficiaryconsent"), - pascal("db_identity.i_benfamilytag"), + pascal("db_identity.i_beneficiarydetails_rmnch", "beneficiaryDetails_RmnchId"), + pascal("db_identity.i_beneficiaryfamilymapping", "BenFamilyMapId"), + pascal("db_identity.i_beneficiarydetails", "BeneficiaryDetailsId"), + pascal("db_identity.i_beneficiarymapping", "BenMapId"), + pascal("db_identity.i_beneficiaryidentity", "BenIdentityId"), + pascal("db_identity.i_householddetails", "houseHoldDetailsId"), + pascal("db_identity.i_beneficiaryimage", "BenImageId"), + pascal("db_identity.i_beneficiaryaddress", "BenAddressID"), + pascal("db_identity.i_beneficiaryservicemapping", "BenServiceMapID"), + pascal("db_identity.m_beneficiaryregidmapping", "BenRegId"), + pascal("db_identity.i_bornbirthdeatils", "BornBirthDeatilsId"), + pascal("db_identity.i_beneficiarycontacts", "BenContactsID"), + pascal("db_identity.i_beneficiaryconsent", "BenConsentID"), + pascal("db_identity.i_benfamilytag", "BenFamilyTagId"), // --- db_iemr : created_by / updated_by --- - snake("db_iemr.ELIGIBLE_COUPLE_TRACKING"), - snake("db_iemr.t_pregnant_woman_register"), - snake("db_iemr.t_eligible_couple_register"), - snake("db_iemr.t_delivery_outcome"), - snake("db_iemr.t_infant_register"), - snake("db_iemr.t_pnc_visit"), - snake("db_iemr.t_anc_visit"), - snake("db_iemr.t_child_register"), - snake("db_iemr.t_pmsma"), + snake("db_iemr.eligible_couple_tracking", "id"), + snake("db_iemr.t_pregnant_woman_register", "id"), + snake("db_iemr.t_eligible_couple_register", "id"), + snake("db_iemr.t_delivery_outcome", "id"), + snake("db_iemr.t_infant_register", "id"), + snake("db_iemr.t_pnc_visit", "ID"), + snake("db_iemr.t_anc_visit", "ID"), + snake("db_iemr.t_child_register", "ID"), + snake("db_iemr.t_pmsma", "id"), // --- db_iemr : CreatedBy / ModifiedBy --- - pascal("db_iemr.t_cbacdetails"), - pascal("db_iemr.t_pnccare"), - pascal("db_iemr.t_anccare"), - pascal("db_iemr.t_benvisitdetail"), - pascal("db_iemr.t_childvaccinedetail1")); + pascal("db_iemr.t_cbacdetails", "id"), + pascal("db_iemr.t_pnccare", "id"), + pascal("db_iemr.t_anccare", "ID"), + pascal("db_iemr.t_benvisitdetail", "BenVisitID"), + pascal("db_iemr.t_childvaccinedetail1", "ID")); } diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java index d03b819..e679b7e 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -49,34 +49,39 @@ public class UsernameRenameRepository { @PersistenceContext private EntityManager entityManager; - /** Rows in {@code table} attributed to {@code userName} in either audit column. */ + /** Rows the rename would update in {@code table} — those the user created. */ public long countAffected(AuditTable table, String userName) { - String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = :userName OR %s = :userName", - table.getQualifiedName(), table.getCreatedByColumn(), table.getModifiedByColumn()); + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = :userName", table.getQualifiedName(), + table.getCreatedByColumn()); Query query = entityManager.createNativeQuery(sql); query.setParameter("userName", userName); return toLong(query.getSingleResult()); } + /** Rows the m_user update itself would touch, so the preview matches the rename report. */ + public long countUserRow(String userName) { + Query query = entityManager + .createNativeQuery("SELECT COUNT(*) FROM db_iemr.m_user WHERE UserName = :userName"); + query.setParameter("userName", userName); + return toLong(query.getSingleResult()); + } + /** - * Repoints both audit columns in one table. + * Repoints both audit columns on the rows this user created. * - *

Deliberately two statements rather than one combined UPDATE: a single - * {@code SET CreatedBy=:new, ModifiedBy=:new WHERE CreatedBy=:old} would - * overwrite ModifiedBy on rows a DIFFERENT user last modified, destroying - * their attribution, and would miss rows this user only modified. + *

Driven by primary key through a derived table rather than filtering the + * UPDATE on the audit column directly. The extra {@code SELECT ... AS temp} + * wrapper is required by MySQL, which will not read from the same table an + * UPDATE targets unless the subquery is materialised (error 1093). * - * @return rows touched across both statements (a row updated in each counts twice) + * @return rows updated */ public long renameInTable(AuditTable table, String oldUserName, String newUserName) { - long updated = executeUpdate(table.getQualifiedName(), table.getCreatedByColumn(), oldUserName, newUserName); - updated += executeUpdate(table.getQualifiedName(), table.getModifiedByColumn(), oldUserName, newUserName); - return updated; - } - - private long executeUpdate(String qualifiedName, String column, String oldUserName, String newUserName) { - String sql = String.format("UPDATE %s SET %s = :newUserName WHERE %s = :oldUserName", qualifiedName, column, - column); + String sql = String.format( + "UPDATE %1$s SET %2$s = :newUserName, %3$s = :newUserName " + + "WHERE %4$s IN (SELECT %4$s FROM (SELECT %4$s FROM %1$s WHERE %2$s = :oldUserName) AS temp)", + table.getQualifiedName(), table.getCreatedByColumn(), table.getModifiedByColumn(), + table.getPrimaryKeyColumn()); Query query = entityManager.createNativeQuery(sql); query.setParameter("newUserName", newUserName); query.setParameter("oldUserName", oldUserName); diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index 4bebb06..27bdb62 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -57,6 +57,11 @@ public UsernameRenameResponse preview(UsernameRenameRequest request) throws Exce validate(request); UsernameRenameResponse response = newResponse(request, true); + + // Listed first, and counted the same way, so the preview lines up row + // for row with what rename() reports. + response.addTable("db_iemr.m_user", usernameRenameRepository.countUserRow(request.getOldUserName())); + for (AuditTable table : UsernameAuditTables.TABLES) { response.addTable(table.getQualifiedName(), usernameRenameRepository.countAffected(table, request.getOldUserName())); From 98c2f34bc5eda29c9680d3f4c9ab1939cc4cc0e7 Mon Sep 17 00:00:00 2001 From: Sneha Date: Tue, 1 Sep 2026 18:56:35 +0530 Subject: [PATCH 06/18] feat(admin): drop the username rename preview endpoint Removes POST /username/renameUsername/preview along with the service method behind it, the two counting queries it used, and the now-unused preview flag on the response. /username/renameUsername is unchanged and remains the only endpoint. Co-Authored-By: Claude Opus 5 --- .../username/UsernameRenameController.java | 16 ------------- .../username/UsernameRenameResponse.java | 11 +-------- .../username/UsernameRenameRepository.java | 17 -------------- .../username/UsernameRenameService.java | 3 --- .../username/UsernameRenameServiceImpl.java | 23 ++----------------- 5 files changed, 3 insertions(+), 67 deletions(-) diff --git a/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java b/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java index 83fe4df..0b30233 100644 --- a/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java +++ b/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java @@ -53,22 +53,6 @@ public class UsernameRenameController { @Autowired private UsernameRenameService usernameRenameService; - @Operation(summary = "Preview a username rename without applying it") - @RequestMapping(value = "/renameUsername/preview", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON, headers = "Authorization") - public String previewRename(@RequestBody UsernameRenameRequest renameRequest, HttpServletRequest request) { - OutputResponse response = new OutputResponse(); - try { - logger.info("previewRename received request"); - response.setResponse( - OutputMapper.gsonWithoutExpose().toJson(usernameRenameService.preview(renameRequest))); - } catch (Exception e) { - logger.error("previewRename failed", e); - response.setError(e); - } - logger.info("previewRename sending response"); - return response.toString(); - } - @Operation(summary = "Rename a username and repoint its audit records") @RequestMapping(value = "/renameUsername", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON, headers = "Authorization") public String renameUsername(@RequestBody UsernameRenameRequest renameRequest, HttpServletRequest request) { diff --git a/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java b/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java index 54202a8..7516787 100644 --- a/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java +++ b/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java @@ -25,14 +25,13 @@ import java.util.Map; /** - * Result of a rename (or of a preview run, where nothing is committed). + * Result of a rename. * {@code rowsPerTable} is ordered so the report reads in execution order. */ public class UsernameRenameResponse { private String oldUserName; private String newUserName; - private boolean preview; private int tablesAffected; private long totalRowsAffected; private Map rowsPerTable = new LinkedHashMap<>(); @@ -61,14 +60,6 @@ public void setNewUserName(String newUserName) { this.newUserName = newUserName; } - public boolean isPreview() { - return preview; - } - - public void setPreview(boolean preview) { - this.preview = preview; - } - public int getTablesAffected() { return tablesAffected; } diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java index e679b7e..0e62288 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -49,23 +49,6 @@ public class UsernameRenameRepository { @PersistenceContext private EntityManager entityManager; - /** Rows the rename would update in {@code table} — those the user created. */ - public long countAffected(AuditTable table, String userName) { - String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = :userName", table.getQualifiedName(), - table.getCreatedByColumn()); - Query query = entityManager.createNativeQuery(sql); - query.setParameter("userName", userName); - return toLong(query.getSingleResult()); - } - - /** Rows the m_user update itself would touch, so the preview matches the rename report. */ - public long countUserRow(String userName) { - Query query = entityManager - .createNativeQuery("SELECT COUNT(*) FROM db_iemr.m_user WHERE UserName = :userName"); - query.setParameter("userName", userName); - return toLong(query.getSingleResult()); - } - /** * Repoints both audit columns on the rows this user created. * diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java index b9bafd4..c0e94dd 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java @@ -26,9 +26,6 @@ public interface UsernameRenameService { - /** Reports what a rename would touch, without committing anything. */ - UsernameRenameResponse preview(UsernameRenameRequest request) throws Exception; - /** Performs the rename across m_user and the whitelisted audit tables. */ UsernameRenameResponse rename(UsernameRenameRequest request) throws Exception; } diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index 27bdb62..ee49c99 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -51,24 +51,6 @@ public class UsernameRenameServiceImpl implements UsernameRenameService { @Autowired private UsernameRenameRepository usernameRenameRepository; - @Override - @Transactional(readOnly = true) - public UsernameRenameResponse preview(UsernameRenameRequest request) throws Exception { - validate(request); - - UsernameRenameResponse response = newResponse(request, true); - - // Listed first, and counted the same way, so the preview lines up row - // for row with what rename() reports. - response.addTable("db_iemr.m_user", usernameRenameRepository.countUserRow(request.getOldUserName())); - - for (AuditTable table : UsernameAuditTables.TABLES) { - response.addTable(table.getQualifiedName(), - usernameRenameRepository.countAffected(table, request.getOldUserName())); - } - return response; - } - /** * Runs in one transaction spanning db_iemr and db_identity, so a failure * part-way through rolls the whole rename back rather than stranding the @@ -83,7 +65,7 @@ public UsernameRenameResponse rename(UsernameRenameRequest request) throws Excep String newUserName = request.getNewUserName(); logger.info("Username rename starting: {} -> {}", oldUserName, newUserName); - UsernameRenameResponse response = newResponse(request, false); + UsernameRenameResponse response = newResponse(request); // The identity row goes first: if the unique key on UserName or // EmployeeID rejects the new value, nothing else has been touched yet. @@ -101,11 +83,10 @@ public UsernameRenameResponse rename(UsernameRenameRequest request) throws Excep return response; } - private UsernameRenameResponse newResponse(UsernameRenameRequest request, boolean preview) { + private UsernameRenameResponse newResponse(UsernameRenameRequest request) { UsernameRenameResponse response = new UsernameRenameResponse(); response.setOldUserName(request.getOldUserName()); response.setNewUserName(request.getNewUserName()); - response.setPreview(preview); return response; } From beb39946a6105336235c128037e2cb9cb49419f9 Mon Sep 17 00:00:00 2001 From: Sneha Date: Tue, 1 Sep 2026 19:13:24 +0530 Subject: [PATCH 07/18] feat(admin): accept an optional new Employee ID on username rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmployeeID was hardcoded to the new username in both branches of the m_user update. It is now only touched when the request supplies a new value, so renaming a username no longer implies changing the Employee ID. The SET clause is built from what was actually asked for. Splits the uniqueness check in two. UserName and EmployeeID hold separate UNIQUE keys on m_user, so each conflicts only with its own column — the previous combined check rejected a new username merely because some row used it as an Employee ID, which was only correct while the two were forced to match. Both checks exclude the row being renamed, so re-entering the value that row already holds is not reported as a conflict with itself. Co-Authored-By: Claude Opus 5 --- .../model/username/UsernameRenameRequest.java | 25 ++++++++ .../username/UsernameRenameRepository.java | 59 +++++++++++++------ .../username/UsernameRenameServiceImpl.java | 34 ++++++++++- 3 files changed, 98 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java b/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java index c360fb9..8bf4b00 100644 --- a/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java +++ b/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java @@ -32,6 +32,15 @@ public class UsernameRenameRequest { private String oldUserName; private String newUserName; + + /** + * Employee ID is left untouched unless this is set. It carries its own + * UNIQUE key on m_user and is not required to track the username, so a + * rename does not imply a new Employee ID. + */ + private boolean updateEmployeeId = false; + private String newEmployeeId; + private boolean updateContactFields = true; public String getOldUserName() { @@ -50,6 +59,22 @@ public void setNewUserName(String newUserName) { this.newUserName = newUserName; } + public boolean isUpdateEmployeeId() { + return updateEmployeeId; + } + + public void setUpdateEmployeeId(boolean updateEmployeeId) { + this.updateEmployeeId = updateEmployeeId; + } + + public String getNewEmployeeId() { + return newEmployeeId; + } + + public void setNewEmployeeId(String newEmployeeId) { + this.newEmployeeId = newEmployeeId; + } + public boolean isUpdateContactFields() { return updateContactFields; } diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java index 0e62288..2db62d5 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -72,19 +72,30 @@ public long renameInTable(AuditTable table, String oldUserName, String newUserNa } /** - * Updates the identity row itself. EmployeeID tracks the username per the - * existing bulk-registration convention; the contact columns are only - * rewritten where the username is known to be the user's mobile number. + * Updates the identity row itself. + * + *

UserName always changes. EmployeeID is only touched when a new value is + * supplied — it holds its own UNIQUE key and need not track the username. + * The contact columns are only rewritten where the username is known to be + * the user's mobile number. */ - public long renameUserRow(String oldUserName, String newUserName, boolean updateContactFields) { - String sql = updateContactFields - ? "UPDATE db_iemr.m_user SET UserName = :newUserName, EmployeeID = :newUserName, " - + "EmergencyContactNo = :newUserName, ContactNo = :newUserName WHERE UserName = :oldUserName" - : "UPDATE db_iemr.m_user SET UserName = :newUserName, EmployeeID = :newUserName " - + "WHERE UserName = :oldUserName"; - Query query = entityManager.createNativeQuery(sql); + public long renameUserRow(String oldUserName, String newUserName, String newEmployeeId, + boolean updateContactFields) { + StringBuilder sql = new StringBuilder("UPDATE db_iemr.m_user SET UserName = :newUserName"); + if (newEmployeeId != null) { + sql.append(", EmployeeID = :newEmployeeId"); + } + if (updateContactFields) { + sql.append(", EmergencyContactNo = :newUserName, ContactNo = :newUserName"); + } + sql.append(" WHERE UserName = :oldUserName"); + + Query query = entityManager.createNativeQuery(sql.toString()); query.setParameter("newUserName", newUserName); query.setParameter("oldUserName", oldUserName); + if (newEmployeeId != null) { + query.setParameter("newEmployeeId", newEmployeeId); + } return query.executeUpdate(); } @@ -96,14 +107,28 @@ public boolean userExists(String userName) { } /** - * Both UserName and EmployeeID carry UNIQUE keys on m_user and the rename - * writes the new value into both, so either being taken blocks the rename. + * UserName and EmployeeID carry separate UNIQUE keys on m_user, so each is + * only in conflict with its own column. Checked independently now that a + * rename no longer forces EmployeeID to equal the username. */ - public boolean userNameOrEmployeeIdTaken(String userName) { - Query query = entityManager.createNativeQuery( - "SELECT COUNT(*) FROM db_iemr.m_user WHERE UserName = :userName OR EmployeeID = :userName"); - query.setParameter("userName", userName); - return toLong(query.getSingleResult()) > 0; + public boolean userNameTaken(String userName, String excludeUserName) { + return countMatching("UserName", userName, excludeUserName) > 0; + } + + public boolean employeeIdTaken(String employeeId, String excludeUserName) { + return countMatching("EmployeeID", employeeId, excludeUserName) > 0; + } + + /** + * {@code excludeUserName} skips the row being renamed, so re-entering the + * value that row already holds is not reported as a conflict with itself. + */ + private long countMatching(String column, String value, String excludeUserName) { + Query query = entityManager.createNativeQuery(String.format( + "SELECT COUNT(*) FROM db_iemr.m_user WHERE %s = :value AND UserName <> :excludeUserName", column)); + query.setParameter("value", value); + query.setParameter("excludeUserName", excludeUserName); + return toLong(query.getSingleResult()); } private long toLong(Object result) { diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index ee49c99..ceae888 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -40,6 +40,7 @@ public class UsernameRenameServiceImpl implements UsernameRenameService { /** m_user.UserName and m_user.EmployeeID are both varchar(20). */ private static final int MAX_USERNAME_LENGTH = 20; + private static final int MAX_EMPLOYEE_ID_LENGTH = 20; /** * m_user.ContactNo is varchar(12) — the tightest column the rename writes @@ -70,6 +71,7 @@ public UsernameRenameResponse rename(UsernameRenameRequest request) throws Excep // The identity row goes first: if the unique key on UserName or // EmployeeID rejects the new value, nothing else has been touched yet. long userRows = usernameRenameRepository.renameUserRow(oldUserName, newUserName, + request.isUpdateEmployeeId() ? request.getNewEmployeeId() : null, request.isUpdateContactFields()); response.addTable("db_iemr.m_user", userRows); @@ -119,13 +121,39 @@ private void validate(UsernameRenameRequest request) throws Exception { if (!usernameRenameRepository.userExists(oldUserName)) { throw new IllegalArgumentException("No user found with username " + oldUserName); } - if (usernameRenameRepository.userNameOrEmployeeIdTaken(newUserName)) { - throw new IllegalArgumentException( - "Username " + newUserName + " is already in use as a username or employee ID"); + if (usernameRenameRepository.userNameTaken(newUserName, oldUserName)) { + throw new IllegalArgumentException("Username " + newUserName + " is already in use"); } request.setOldUserName(oldUserName); request.setNewUserName(newUserName); + validateEmployeeId(request); + } + + /** + * Employee ID is optional: left alone entirely unless the caller asks for it. + * It is checked against its own column only, since UserName and EmployeeID + * are independent UNIQUE keys. + */ + private void validateEmployeeId(UsernameRenameRequest request) throws Exception { + if (!request.isUpdateEmployeeId()) { + request.setNewEmployeeId(null); + return; + } + + String newEmployeeId = trimToNull(request.getNewEmployeeId()); + if (newEmployeeId == null) { + throw new IllegalArgumentException("New employee ID is required when updating employee ID"); + } + if (newEmployeeId.length() > MAX_EMPLOYEE_ID_LENGTH) { + throw new IllegalArgumentException( + "New employee ID exceeds " + MAX_EMPLOYEE_ID_LENGTH + " characters (m_user.EmployeeID limit)"); + } + if (usernameRenameRepository.employeeIdTaken(newEmployeeId, request.getOldUserName())) { + throw new IllegalArgumentException("Employee ID " + newEmployeeId + " is already in use"); + } + + request.setNewEmployeeId(newEmployeeId); } private String trimToNull(String value) { From 400bb629e02d5c8219bd883dd0eb4f55e063882e Mon Sep 17 00:00:00 2001 From: Sneha Date: Tue, 1 Sep 2026 19:26:51 +0530 Subject: [PATCH 08/18] feat(admin): add username/employee ID availability endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /username/checkAvailability so the screen can report a duplicate as it is typed rather than at submit. It reuses the same repository checks rename() runs, so the two cannot disagree — including counting soft-deleted rows, which still hold m_user's UNIQUE keys. Co-Authored-By: Claude Opus 5 --- .../username/UsernameRenameController.java | 14 +++++ .../UsernameAvailabilityResponse.java | 52 +++++++++++++++++++ .../username/UsernameRenameService.java | 4 ++ .../username/UsernameRenameServiceImpl.java | 32 ++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 src/main/java/com/iemr/admin/model/username/UsernameAvailabilityResponse.java diff --git a/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java b/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java index 0b30233..14b3ac8 100644 --- a/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java +++ b/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java @@ -53,6 +53,20 @@ public class UsernameRenameController { @Autowired private UsernameRenameService usernameRenameService; + @Operation(summary = "Check whether a username / employee ID are free to take") + @RequestMapping(value = "/checkAvailability", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON, headers = "Authorization") + public String checkAvailability(@RequestBody UsernameRenameRequest renameRequest, HttpServletRequest request) { + OutputResponse response = new OutputResponse(); + try { + response.setResponse( + OutputMapper.gsonWithoutExpose().toJson(usernameRenameService.checkAvailability(renameRequest))); + } catch (Exception e) { + logger.error("checkAvailability failed", e); + response.setError(e); + } + return response.toString(); + } + @Operation(summary = "Rename a username and repoint its audit records") @RequestMapping(value = "/renameUsername", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON, headers = "Authorization") public String renameUsername(@RequestBody UsernameRenameRequest renameRequest, HttpServletRequest request) { diff --git a/src/main/java/com/iemr/admin/model/username/UsernameAvailabilityResponse.java b/src/main/java/com/iemr/admin/model/username/UsernameAvailabilityResponse.java new file mode 100644 index 0000000..22b8370 --- /dev/null +++ b/src/main/java/com/iemr/admin/model/username/UsernameAvailabilityResponse.java @@ -0,0 +1,52 @@ +/* +* 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.admin.model.username; + +/** + * Whether a proposed username / employee ID can be taken. + * + *

Answered with the same repository checks the rename itself runs, so the + * screen and the server cannot disagree. In particular both count soft-deleted + * rows: m_user's UNIQUE keys do not exclude them, so a name held by a deleted + * user is genuinely unavailable even though it looks free. + */ +public class UsernameAvailabilityResponse { + + private boolean userNameAvailable = true; + private boolean employeeIdAvailable = true; + + public boolean isUserNameAvailable() { + return userNameAvailable; + } + + public void setUserNameAvailable(boolean userNameAvailable) { + this.userNameAvailable = userNameAvailable; + } + + public boolean isEmployeeIdAvailable() { + return employeeIdAvailable; + } + + public void setEmployeeIdAvailable(boolean employeeIdAvailable) { + this.employeeIdAvailable = employeeIdAvailable; + } +} diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java index c0e94dd..bc6adfb 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java @@ -21,11 +21,15 @@ */ package com.iemr.admin.service.username; +import com.iemr.admin.model.username.UsernameAvailabilityResponse; import com.iemr.admin.model.username.UsernameRenameRequest; import com.iemr.admin.model.username.UsernameRenameResponse; public interface UsernameRenameService { + /** Reports whether a proposed username / employee ID are free to take. */ + UsernameAvailabilityResponse checkAvailability(UsernameRenameRequest request) throws Exception; + /** Performs the rename across m_user and the whitelisted audit tables. */ UsernameRenameResponse rename(UsernameRenameRequest request) throws Exception; } diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index ceae888..3309178 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -27,6 +27,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.iemr.admin.model.username.UsernameAvailabilityResponse; import com.iemr.admin.model.username.UsernameRenameRequest; import com.iemr.admin.model.username.UsernameRenameResponse; import com.iemr.admin.repository.username.UsernameAuditTables; @@ -52,6 +53,37 @@ public class UsernameRenameServiceImpl implements UsernameRenameService { @Autowired private UsernameRenameRepository usernameRenameRepository; + /** + * Uses the same repository checks as {@link #rename}, so a name the screen + * reports as free cannot then be rejected on submit. Blank input is treated + * as available — there is nothing to clash with yet. + */ + @Override + @Transactional(readOnly = true) + public UsernameAvailabilityResponse checkAvailability(UsernameRenameRequest request) throws Exception { + UsernameAvailabilityResponse response = new UsernameAvailabilityResponse(); + if (request == null) { + return response; + } + + String oldUserName = trimToNull(request.getOldUserName()); + if (oldUserName == null) { + throw new IllegalArgumentException("Current username is required"); + } + + String newUserName = trimToNull(request.getNewUserName()); + if (newUserName != null && !newUserName.equals(oldUserName)) { + response.setUserNameAvailable(!usernameRenameRepository.userNameTaken(newUserName, oldUserName)); + } + + String newEmployeeId = trimToNull(request.getNewEmployeeId()); + if (newEmployeeId != null) { + response.setEmployeeIdAvailable(!usernameRenameRepository.employeeIdTaken(newEmployeeId, oldUserName)); + } + + return response; + } + /** * Runs in one transaction spanning db_iemr and db_identity, so a failure * part-way through rolls the whole rename back rather than stranding the From 9ebf6612114b7b6e32d1b35acf45e4a331523cfb Mon Sep 17 00:00:00 2001 From: Sneha Date: Wed, 2 Sep 2026 11:35:55 +0530 Subject: [PATCH 09/18] feat(admin): update username and employee ID independently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both new values are now optional. Each is normalised to null when blank or already equal to what the row holds, and the m_user UPDATE is built from whichever columns actually change — so a username-only change, an employee-ID-only change, or both are all valid. The request is rejected only when neither would change anything. The 28-table audit sweep is skipped entirely when the username is unchanged: CreatedBy/ModifiedBy record the username, so an employee-ID-only change leaves every audit row already correct. That also keeps such a change off the unindexed scan path. Drops the updateEmployeeId flag (supplying a value is the intent) and the /username/checkAvailability endpoint, since the screen now uses the same m/FindEmployeeByName and m/FindEmployeeDetails checks as Employee Master. Co-Authored-By: Claude Opus 5 --- .../username/UsernameRenameController.java | 14 --- .../UsernameAvailabilityResponse.java | 52 -------- .../model/username/UsernameRenameRequest.java | 15 +-- .../username/UsernameRenameRepository.java | 44 +++++-- .../username/UsernameRenameService.java | 4 - .../username/UsernameRenameServiceImpl.java | 115 +++++++----------- 6 files changed, 79 insertions(+), 165 deletions(-) delete mode 100644 src/main/java/com/iemr/admin/model/username/UsernameAvailabilityResponse.java diff --git a/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java b/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java index 14b3ac8..0b30233 100644 --- a/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java +++ b/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java @@ -53,20 +53,6 @@ public class UsernameRenameController { @Autowired private UsernameRenameService usernameRenameService; - @Operation(summary = "Check whether a username / employee ID are free to take") - @RequestMapping(value = "/checkAvailability", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON, headers = "Authorization") - public String checkAvailability(@RequestBody UsernameRenameRequest renameRequest, HttpServletRequest request) { - OutputResponse response = new OutputResponse(); - try { - response.setResponse( - OutputMapper.gsonWithoutExpose().toJson(usernameRenameService.checkAvailability(renameRequest))); - } catch (Exception e) { - logger.error("checkAvailability failed", e); - response.setError(e); - } - return response.toString(); - } - @Operation(summary = "Rename a username and repoint its audit records") @RequestMapping(value = "/renameUsername", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON, headers = "Authorization") public String renameUsername(@RequestBody UsernameRenameRequest renameRequest, HttpServletRequest request) { diff --git a/src/main/java/com/iemr/admin/model/username/UsernameAvailabilityResponse.java b/src/main/java/com/iemr/admin/model/username/UsernameAvailabilityResponse.java deleted file mode 100644 index 22b8370..0000000 --- a/src/main/java/com/iemr/admin/model/username/UsernameAvailabilityResponse.java +++ /dev/null @@ -1,52 +0,0 @@ -/* -* 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.admin.model.username; - -/** - * Whether a proposed username / employee ID can be taken. - * - *

Answered with the same repository checks the rename itself runs, so the - * screen and the server cannot disagree. In particular both count soft-deleted - * rows: m_user's UNIQUE keys do not exclude them, so a name held by a deleted - * user is genuinely unavailable even though it looks free. - */ -public class UsernameAvailabilityResponse { - - private boolean userNameAvailable = true; - private boolean employeeIdAvailable = true; - - public boolean isUserNameAvailable() { - return userNameAvailable; - } - - public void setUserNameAvailable(boolean userNameAvailable) { - this.userNameAvailable = userNameAvailable; - } - - public boolean isEmployeeIdAvailable() { - return employeeIdAvailable; - } - - public void setEmployeeIdAvailable(boolean employeeIdAvailable) { - this.employeeIdAvailable = employeeIdAvailable; - } -} diff --git a/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java b/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java index 8bf4b00..d8e807b 100644 --- a/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java +++ b/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java @@ -34,11 +34,10 @@ public class UsernameRenameRequest { private String newUserName; /** - * Employee ID is left untouched unless this is set. It carries its own - * UNIQUE key on m_user and is not required to track the username, so a - * rename does not imply a new Employee ID. + * Both new values are optional and independent. A blank value, or one equal + * to what the row already holds, means "leave this column alone" — so the + * caller can change the username, the employee ID, or both. */ - private boolean updateEmployeeId = false; private String newEmployeeId; private boolean updateContactFields = true; @@ -59,14 +58,6 @@ public void setNewUserName(String newUserName) { this.newUserName = newUserName; } - public boolean isUpdateEmployeeId() { - return updateEmployeeId; - } - - public void setUpdateEmployeeId(boolean updateEmployeeId) { - this.updateEmployeeId = updateEmployeeId; - } - public String getNewEmployeeId() { return newEmployeeId; } diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java index 2db62d5..01c1d83 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -22,6 +22,8 @@ package com.iemr.admin.repository.username; import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; import org.springframework.stereotype.Repository; @@ -71,28 +73,46 @@ public long renameInTable(AuditTable table, String oldUserName, String newUserNa return query.executeUpdate(); } + /** Current Employee ID for a user, or null where none is set. */ + public String currentEmployeeId(String userName) { + Query query = entityManager + .createNativeQuery("SELECT EmployeeID FROM db_iemr.m_user WHERE UserName = :userName"); + query.setParameter("userName", userName); + List rows = query.getResultList(); + return rows.isEmpty() ? null : (String) rows.get(0); + } + /** - * Updates the identity row itself. + * Updates the identity row, touching only the columns that actually change. * - *

UserName always changes. EmployeeID is only touched when a new value is - * supplied — it holds its own UNIQUE key and need not track the username. - * The contact columns are only rewritten where the username is known to be - * the user's mobile number. + *

Either new value may be null, meaning "leave alone". The contact + * columns follow the username, so they are only rewritten alongside it. + * + * @return rows updated, or 0 when there was nothing to change */ public long renameUserRow(String oldUserName, String newUserName, String newEmployeeId, boolean updateContactFields) { - StringBuilder sql = new StringBuilder("UPDATE db_iemr.m_user SET UserName = :newUserName"); + List assignments = new ArrayList<>(); + if (newUserName != null) { + assignments.add("UserName = :newUserName"); + if (updateContactFields) { + assignments.add("EmergencyContactNo = :newUserName"); + assignments.add("ContactNo = :newUserName"); + } + } if (newEmployeeId != null) { - sql.append(", EmployeeID = :newEmployeeId"); + assignments.add("EmployeeID = :newEmployeeId"); } - if (updateContactFields) { - sql.append(", EmergencyContactNo = :newUserName, ContactNo = :newUserName"); + if (assignments.isEmpty()) { + return 0; } - sql.append(" WHERE UserName = :oldUserName"); - Query query = entityManager.createNativeQuery(sql.toString()); - query.setParameter("newUserName", newUserName); + Query query = entityManager.createNativeQuery("UPDATE db_iemr.m_user SET " + String.join(", ", assignments) + + " WHERE UserName = :oldUserName"); query.setParameter("oldUserName", oldUserName); + if (newUserName != null) { + query.setParameter("newUserName", newUserName); + } if (newEmployeeId != null) { query.setParameter("newEmployeeId", newEmployeeId); } diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java index bc6adfb..c0e94dd 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java @@ -21,15 +21,11 @@ */ package com.iemr.admin.service.username; -import com.iemr.admin.model.username.UsernameAvailabilityResponse; import com.iemr.admin.model.username.UsernameRenameRequest; import com.iemr.admin.model.username.UsernameRenameResponse; public interface UsernameRenameService { - /** Reports whether a proposed username / employee ID are free to take. */ - UsernameAvailabilityResponse checkAvailability(UsernameRenameRequest request) throws Exception; - /** Performs the rename across m_user and the whitelisted audit tables. */ UsernameRenameResponse rename(UsernameRenameRequest request) throws Exception; } diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index 3309178..58b2a8d 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -27,7 +27,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.iemr.admin.model.username.UsernameAvailabilityResponse; import com.iemr.admin.model.username.UsernameRenameRequest; import com.iemr.admin.model.username.UsernameRenameResponse; import com.iemr.admin.repository.username.UsernameAuditTables; @@ -53,37 +52,6 @@ public class UsernameRenameServiceImpl implements UsernameRenameService { @Autowired private UsernameRenameRepository usernameRenameRepository; - /** - * Uses the same repository checks as {@link #rename}, so a name the screen - * reports as free cannot then be rejected on submit. Blank input is treated - * as available — there is nothing to clash with yet. - */ - @Override - @Transactional(readOnly = true) - public UsernameAvailabilityResponse checkAvailability(UsernameRenameRequest request) throws Exception { - UsernameAvailabilityResponse response = new UsernameAvailabilityResponse(); - if (request == null) { - return response; - } - - String oldUserName = trimToNull(request.getOldUserName()); - if (oldUserName == null) { - throw new IllegalArgumentException("Current username is required"); - } - - String newUserName = trimToNull(request.getNewUserName()); - if (newUserName != null && !newUserName.equals(oldUserName)) { - response.setUserNameAvailable(!usernameRenameRepository.userNameTaken(newUserName, oldUserName)); - } - - String newEmployeeId = trimToNull(request.getNewEmployeeId()); - if (newEmployeeId != null) { - response.setEmployeeIdAvailable(!usernameRenameRepository.employeeIdTaken(newEmployeeId, oldUserName)); - } - - return response; - } - /** * Runs in one transaction spanning db_iemr and db_identity, so a failure * part-way through rolls the whole rename back rather than stranding the @@ -96,24 +64,31 @@ public UsernameRenameResponse rename(UsernameRenameRequest request) throws Excep String oldUserName = request.getOldUserName(); String newUserName = request.getNewUserName(); - logger.info("Username rename starting: {} -> {}", oldUserName, newUserName); + String newEmployeeId = request.getNewEmployeeId(); + logger.info("Username rename starting: user {} -> {}, employeeId -> {}", oldUserName, + newUserName == null ? "(unchanged)" : newUserName, + newEmployeeId == null ? "(unchanged)" : newEmployeeId); UsernameRenameResponse response = newResponse(request); - // The identity row goes first: if the unique key on UserName or - // EmployeeID rejects the new value, nothing else has been touched yet. - long userRows = usernameRenameRepository.renameUserRow(oldUserName, newUserName, - request.isUpdateEmployeeId() ? request.getNewEmployeeId() : null, + // The identity row goes first: if a unique key rejects either new value, + // nothing else has been touched yet. + long userRows = usernameRenameRepository.renameUserRow(oldUserName, newUserName, newEmployeeId, request.isUpdateContactFields()); response.addTable("db_iemr.m_user", userRows); - for (AuditTable table : UsernameAuditTables.TABLES) { - long rows = usernameRenameRepository.renameInTable(table, oldUserName, newUserName); - response.addTable(table.getQualifiedName(), rows); + // CreatedBy/ModifiedBy record the username, so the sweep is only needed + // when the username itself changed. An employee-ID-only change leaves + // every audit row already correct. + if (newUserName != null) { + for (AuditTable table : UsernameAuditTables.TABLES) { + response.addTable(table.getQualifiedName(), + usernameRenameRepository.renameInTable(table, oldUserName, newUserName)); + } } - logger.info("Username rename complete: {} -> {}, {} rows across {} tables", oldUserName, newUserName, - response.getTotalRowsAffected(), response.getTablesAffected()); + logger.info("Username rename complete: {} rows across {} tables", response.getTotalRowsAffected(), + response.getTablesAffected()); return response; } @@ -124,22 +99,37 @@ private UsernameRenameResponse newResponse(UsernameRenameRequest request) { return response; } + /** + * Both new values are optional. Each is normalised to null when it is blank + * or already equal to what the row holds, which is what the repository + * reads as "leave this column alone". At least one must actually change. + */ private void validate(UsernameRenameRequest request) throws Exception { if (request == null) { throw new IllegalArgumentException("Request body is required"); } String oldUserName = trimToNull(request.getOldUserName()); - String newUserName = trimToNull(request.getNewUserName()); - if (oldUserName == null) { throw new IllegalArgumentException("Current username is required"); } - if (newUserName == null) { - throw new IllegalArgumentException("New username is required"); + if (!usernameRenameRepository.userExists(oldUserName)) { + throw new IllegalArgumentException("No user found with username " + oldUserName); } - if (oldUserName.equals(newUserName)) { - throw new IllegalArgumentException("New username is the same as the current username"); + request.setOldUserName(oldUserName); + + request.setNewUserName(resolveNewUserName(request, oldUserName)); + request.setNewEmployeeId(resolveNewEmployeeId(request, oldUserName)); + + if (request.getNewUserName() == null && request.getNewEmployeeId() == null) { + throw new IllegalArgumentException("Nothing to update — enter a new username or a new employee ID"); + } + } + + private String resolveNewUserName(UsernameRenameRequest request, String oldUserName) throws Exception { + String newUserName = trimToNull(request.getNewUserName()); + if (newUserName == null || newUserName.equals(oldUserName)) { + return null; } if (newUserName.length() > MAX_USERNAME_LENGTH) { throw new IllegalArgumentException( @@ -150,42 +140,25 @@ private void validate(UsernameRenameRequest request) throws Exception { + " characters and cannot be written to ContactNo. Either shorten it or " + "turn off updating contact numbers."); } - if (!usernameRenameRepository.userExists(oldUserName)) { - throw new IllegalArgumentException("No user found with username " + oldUserName); - } if (usernameRenameRepository.userNameTaken(newUserName, oldUserName)) { throw new IllegalArgumentException("Username " + newUserName + " is already in use"); } - - request.setOldUserName(oldUserName); - request.setNewUserName(newUserName); - validateEmployeeId(request); + return newUserName; } - /** - * Employee ID is optional: left alone entirely unless the caller asks for it. - * It is checked against its own column only, since UserName and EmployeeID - * are independent UNIQUE keys. - */ - private void validateEmployeeId(UsernameRenameRequest request) throws Exception { - if (!request.isUpdateEmployeeId()) { - request.setNewEmployeeId(null); - return; - } - + private String resolveNewEmployeeId(UsernameRenameRequest request, String oldUserName) throws Exception { String newEmployeeId = trimToNull(request.getNewEmployeeId()); - if (newEmployeeId == null) { - throw new IllegalArgumentException("New employee ID is required when updating employee ID"); + if (newEmployeeId == null || newEmployeeId.equals(usernameRenameRepository.currentEmployeeId(oldUserName))) { + return null; } if (newEmployeeId.length() > MAX_EMPLOYEE_ID_LENGTH) { throw new IllegalArgumentException( "New employee ID exceeds " + MAX_EMPLOYEE_ID_LENGTH + " characters (m_user.EmployeeID limit)"); } - if (usernameRenameRepository.employeeIdTaken(newEmployeeId, request.getOldUserName())) { + if (usernameRenameRepository.employeeIdTaken(newEmployeeId, oldUserName)) { throw new IllegalArgumentException("Employee ID " + newEmployeeId + " is already in use"); } - - request.setNewEmployeeId(newEmployeeId); + return newEmployeeId; } private String trimToNull(String value) { From 14cde075e52ee60f7f06d07cc91a00011e6fda67 Mon Sep 17 00:00:00 2001 From: Sneha Date: Wed, 2 Sep 2026 11:48:32 +0530 Subject: [PATCH 10/18] feat(admin): address the rename target by userID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takes userID on the rename request and uses it as the identity of the row being changed. The username is the very thing being renamed, so a string match is a poor way to address it; the primary key is not. The m_user UPDATE is now keyed on UserID, and both uniqueness checks exclude self by UserID rather than by username — the same way the Employee Master edit path does it via findEmployeeByNameForUpdate. The old username is now read from the row rather than trusted from the request, and a mismatch is rejected: if the row was renamed after the screen loaded its list, sweeping on the stale username would repoint the wrong audit records. Co-Authored-By: Claude Opus 5 --- .../model/username/UsernameRenameRequest.java | 15 +++++ .../username/UsernameRenameRepository.java | 64 ++++++++++--------- .../username/UsernameRenameServiceImpl.java | 35 ++++++---- 3 files changed, 72 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java b/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java index d8e807b..a73c29e 100644 --- a/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java +++ b/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java @@ -30,6 +30,13 @@ */ public class UsernameRenameRequest { + /** + * Identifies the row to change. The username is itself being renamed, so the + * primary key is the stable way to address it; oldUserName is still required + * because the audit sweep matches on the username string. + */ + private Integer userID; + private String oldUserName; private String newUserName; @@ -42,6 +49,14 @@ public class UsernameRenameRequest { private boolean updateContactFields = true; + public Integer getUserID() { + return userID; + } + + public void setUserID(Integer userID) { + this.userID = userID; + } + public String getOldUserName() { return oldUserName; } diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java index 01c1d83..57156ac 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -73,11 +73,19 @@ public long renameInTable(AuditTable table, String oldUserName, String newUserNa return query.executeUpdate(); } - /** Current Employee ID for a user, or null where none is set. */ - public String currentEmployeeId(String userName) { - Query query = entityManager - .createNativeQuery("SELECT EmployeeID FROM db_iemr.m_user WHERE UserName = :userName"); - query.setParameter("userName", userName); + /** The row's current username, or null when no user has that ID. */ + public String currentUserName(Integer userID) { + return single("SELECT UserName FROM db_iemr.m_user WHERE UserID = :userID", userID); + } + + /** The row's current Employee ID, or null where none is set. */ + public String currentEmployeeId(Integer userID) { + return single("SELECT EmployeeID FROM db_iemr.m_user WHERE UserID = :userID", userID); + } + + private String single(String sql, Integer userID) { + Query query = entityManager.createNativeQuery(sql); + query.setParameter("userID", userID); List rows = query.getResultList(); return rows.isEmpty() ? null : (String) rows.get(0); } @@ -85,12 +93,16 @@ public String currentEmployeeId(String userName) { /** * Updates the identity row, touching only the columns that actually change. * + *

Targeted by UserID rather than by username: the username is the very + * thing being changed, and the primary key is unambiguous where a string + * match is not. + * *

Either new value may be null, meaning "leave alone". The contact * columns follow the username, so they are only rewritten alongside it. * * @return rows updated, or 0 when there was nothing to change */ - public long renameUserRow(String oldUserName, String newUserName, String newEmployeeId, + public long renameUserRow(Integer userID, String newUserName, String newEmployeeId, boolean updateContactFields) { List assignments = new ArrayList<>(); if (newUserName != null) { @@ -107,9 +119,9 @@ public long renameUserRow(String oldUserName, String newUserName, String newEmpl return 0; } - Query query = entityManager.createNativeQuery("UPDATE db_iemr.m_user SET " + String.join(", ", assignments) - + " WHERE UserName = :oldUserName"); - query.setParameter("oldUserName", oldUserName); + Query query = entityManager.createNativeQuery( + "UPDATE db_iemr.m_user SET " + String.join(", ", assignments) + " WHERE UserID = :userID"); + query.setParameter("userID", userID); if (newUserName != null) { query.setParameter("newUserName", newUserName); } @@ -119,35 +131,27 @@ public long renameUserRow(String oldUserName, String newUserName, String newEmpl return query.executeUpdate(); } - public boolean userExists(String userName) { - Query query = entityManager - .createNativeQuery("SELECT COUNT(*) FROM db_iemr.m_user WHERE UserName = :userName"); - query.setParameter("userName", userName); - return toLong(query.getSingleResult()) > 0; - } - /** * UserName and EmployeeID carry separate UNIQUE keys on m_user, so each is - * only in conflict with its own column. Checked independently now that a - * rename no longer forces EmployeeID to equal the username. + * only in conflict with its own column. + * + *

Self is excluded by UserID, matching how the Employee Master edit path + * does it (findEmployeeByNameForUpdate), so re-entering a value the row + * already holds is not reported as a conflict with itself. */ - public boolean userNameTaken(String userName, String excludeUserName) { - return countMatching("UserName", userName, excludeUserName) > 0; + public boolean userNameTaken(String userName, Integer excludeUserID) { + return countMatching("UserName", userName, excludeUserID) > 0; } - public boolean employeeIdTaken(String employeeId, String excludeUserName) { - return countMatching("EmployeeID", employeeId, excludeUserName) > 0; + public boolean employeeIdTaken(String employeeId, Integer excludeUserID) { + return countMatching("EmployeeID", employeeId, excludeUserID) > 0; } - /** - * {@code excludeUserName} skips the row being renamed, so re-entering the - * value that row already holds is not reported as a conflict with itself. - */ - private long countMatching(String column, String value, String excludeUserName) { - Query query = entityManager.createNativeQuery(String.format( - "SELECT COUNT(*) FROM db_iemr.m_user WHERE %s = :value AND UserName <> :excludeUserName", column)); + private long countMatching(String column, String value, Integer excludeUserID) { + Query query = entityManager.createNativeQuery(String + .format("SELECT COUNT(*) FROM db_iemr.m_user WHERE %s = :value AND UserID <> :excludeUserID", column)); query.setParameter("value", value); - query.setParameter("excludeUserName", excludeUserName); + query.setParameter("excludeUserID", excludeUserID); return toLong(query.getSingleResult()); } diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index 58b2a8d..c6efd49 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -73,7 +73,7 @@ public UsernameRenameResponse rename(UsernameRenameRequest request) throws Excep // The identity row goes first: if a unique key rejects either new value, // nothing else has been touched yet. - long userRows = usernameRenameRepository.renameUserRow(oldUserName, newUserName, newEmployeeId, + long userRows = usernameRenameRepository.renameUserRow(request.getUserID(), newUserName, newEmployeeId, request.isUpdateContactFields()); response.addTable("db_iemr.m_user", userRows); @@ -109,17 +109,27 @@ private void validate(UsernameRenameRequest request) throws Exception { throw new IllegalArgumentException("Request body is required"); } - String oldUserName = trimToNull(request.getOldUserName()); - if (oldUserName == null) { - throw new IllegalArgumentException("Current username is required"); + if (request.getUserID() == null) { + throw new IllegalArgumentException("User ID is required"); + } + + String storedUserName = usernameRenameRepository.currentUserName(request.getUserID()); + if (storedUserName == null) { + throw new IllegalArgumentException("No user found with ID " + request.getUserID()); } - if (!usernameRenameRepository.userExists(oldUserName)) { - throw new IllegalArgumentException("No user found with username " + oldUserName); + + // Guard against a stale screen: if the row has been renamed since the + // list was loaded, the audit sweep would match the wrong username. + String oldUserName = trimToNull(request.getOldUserName()); + if (oldUserName != null && !oldUserName.equals(storedUserName)) { + throw new IllegalArgumentException("User " + request.getUserID() + " is now named " + storedUserName + + ", not " + oldUserName + ". Reload the user list and try again."); } - request.setOldUserName(oldUserName); + request.setOldUserName(storedUserName); + oldUserName = storedUserName; request.setNewUserName(resolveNewUserName(request, oldUserName)); - request.setNewEmployeeId(resolveNewEmployeeId(request, oldUserName)); + request.setNewEmployeeId(resolveNewEmployeeId(request)); if (request.getNewUserName() == null && request.getNewEmployeeId() == null) { throw new IllegalArgumentException("Nothing to update — enter a new username or a new employee ID"); @@ -140,22 +150,23 @@ private String resolveNewUserName(UsernameRenameRequest request, String oldUserN + " characters and cannot be written to ContactNo. Either shorten it or " + "turn off updating contact numbers."); } - if (usernameRenameRepository.userNameTaken(newUserName, oldUserName)) { + if (usernameRenameRepository.userNameTaken(newUserName, request.getUserID())) { throw new IllegalArgumentException("Username " + newUserName + " is already in use"); } return newUserName; } - private String resolveNewEmployeeId(UsernameRenameRequest request, String oldUserName) throws Exception { + private String resolveNewEmployeeId(UsernameRenameRequest request) throws Exception { String newEmployeeId = trimToNull(request.getNewEmployeeId()); - if (newEmployeeId == null || newEmployeeId.equals(usernameRenameRepository.currentEmployeeId(oldUserName))) { + if (newEmployeeId == null + || newEmployeeId.equals(usernameRenameRepository.currentEmployeeId(request.getUserID()))) { return null; } if (newEmployeeId.length() > MAX_EMPLOYEE_ID_LENGTH) { throw new IllegalArgumentException( "New employee ID exceeds " + MAX_EMPLOYEE_ID_LENGTH + " characters (m_user.EmployeeID limit)"); } - if (usernameRenameRepository.employeeIdTaken(newEmployeeId, oldUserName)) { + if (usernameRenameRepository.employeeIdTaken(newEmployeeId, request.getUserID())) { throw new IllegalArgumentException("Employee ID " + newEmployeeId + " is already in use"); } return newEmployeeId; From be4c3ccc4b221c88501f465b50aa6fc502db8b7f Mon Sep 17 00:00:00 2001 From: Sneha Date: Wed, 2 Sep 2026 12:53:03 +0530 Subject: [PATCH 11/18] fix(admin): return the employee ID in the rename response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The response carried only the username pair, so a caller had no way to see what happened to the employee ID. Adds oldEmployeeId (read before the update) and newEmployeeId. Also adds userNameUpdated/employeeIdUpdated booleans. OutputResponse re-serialises the envelope with a GsonBuilder that has no serializeNulls, so any null field drops out of the JSON entirely — which is why an unset employee ID appears to be missing rather than null. A primitive boolean always survives that pass, so the caller can tell "unchanged" from "absent". Co-Authored-By: Claude Opus 5 --- .../username/UsernameRenameResponse.java | 50 +++++++++++++++++++ .../username/UsernameRenameServiceImpl.java | 5 ++ 2 files changed, 55 insertions(+) diff --git a/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java b/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java index 7516787..c15f318 100644 --- a/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java +++ b/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java @@ -32,6 +32,24 @@ public class UsernameRenameResponse { private String oldUserName; private String newUserName; + + /** + * The Employee ID before and after. Both mirror the username pair: the "new" + * value is null when the column was left alone, and the old value is read + * before the update so the caller can see what it replaced. + */ + private String oldEmployeeId; + private String newEmployeeId; + + /** + * Whether each column was actually written. Booleans rather than inference + * from the strings above: OutputResponse re-serialises without + * serializeNulls, so a null "new" value drops out of the JSON entirely and + * the caller cannot tell "unchanged" from "missing". A primitive always + * survives that pass. + */ + private boolean userNameUpdated; + private boolean employeeIdUpdated; private int tablesAffected; private long totalRowsAffected; private Map rowsPerTable = new LinkedHashMap<>(); @@ -44,6 +62,38 @@ public void addTable(String table, long rows) { } } + public boolean isUserNameUpdated() { + return userNameUpdated; + } + + public void setUserNameUpdated(boolean userNameUpdated) { + this.userNameUpdated = userNameUpdated; + } + + public boolean isEmployeeIdUpdated() { + return employeeIdUpdated; + } + + public void setEmployeeIdUpdated(boolean employeeIdUpdated) { + this.employeeIdUpdated = employeeIdUpdated; + } + + public String getOldEmployeeId() { + return oldEmployeeId; + } + + public void setOldEmployeeId(String oldEmployeeId) { + this.oldEmployeeId = oldEmployeeId; + } + + public String getNewEmployeeId() { + return newEmployeeId; + } + + public void setNewEmployeeId(String newEmployeeId) { + this.newEmployeeId = newEmployeeId; + } + public String getOldUserName() { return oldUserName; } diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index c6efd49..258aa3b 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -69,7 +69,9 @@ public UsernameRenameResponse rename(UsernameRenameRequest request) throws Excep newUserName == null ? "(unchanged)" : newUserName, newEmployeeId == null ? "(unchanged)" : newEmployeeId); + // Read before the update, so the response can report what was replaced. UsernameRenameResponse response = newResponse(request); + response.setOldEmployeeId(usernameRenameRepository.currentEmployeeId(request.getUserID())); // The identity row goes first: if a unique key rejects either new value, // nothing else has been touched yet. @@ -96,6 +98,9 @@ private UsernameRenameResponse newResponse(UsernameRenameRequest request) { UsernameRenameResponse response = new UsernameRenameResponse(); response.setOldUserName(request.getOldUserName()); response.setNewUserName(request.getNewUserName()); + response.setNewEmployeeId(request.getNewEmployeeId()); + response.setUserNameUpdated(request.getNewUserName() != null); + response.setEmployeeIdUpdated(request.getNewEmployeeId() != null); return response; } From 9fed960d05bcf237350c13a46c0e2bd43a383609 Mon Sep 17 00:00:00 2001 From: Sneha Date: Wed, 2 Sep 2026 13:15:57 +0530 Subject: [PATCH 12/18] feat(admin): drop row/table counts from the rename response Removes rowsPerTable, tablesAffected and totalRowsAffected. The response now reports only what changed: the username and employee ID before and after, plus the flags saying whether each was written. The total row count is still summed and logged, so the scale of a rename remains traceable without exposing the table list to callers. Co-Authored-By: Claude Opus 5 --- .../username/UsernameRenameResponse.java | 29 +------------------ .../username/UsernameRenameServiceImpl.java | 11 ++++--- 2 files changed, 6 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java b/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java index c15f318..d5526bb 100644 --- a/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java +++ b/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java @@ -21,12 +21,8 @@ */ package com.iemr.admin.model.username; -import java.util.LinkedHashMap; -import java.util.Map; - /** - * Result of a rename. - * {@code rowsPerTable} is ordered so the report reads in execution order. + * Result of a rename: what changed, and whether each column was written. */ public class UsernameRenameResponse { @@ -50,17 +46,6 @@ public class UsernameRenameResponse { */ private boolean userNameUpdated; private boolean employeeIdUpdated; - private int tablesAffected; - private long totalRowsAffected; - private Map rowsPerTable = new LinkedHashMap<>(); - - public void addTable(String table, long rows) { - rowsPerTable.put(table, rows); - totalRowsAffected += rows; - if (rows > 0) { - tablesAffected++; - } - } public boolean isUserNameUpdated() { return userNameUpdated; @@ -109,16 +94,4 @@ public String getNewUserName() { public void setNewUserName(String newUserName) { this.newUserName = newUserName; } - - public int getTablesAffected() { - return tablesAffected; - } - - public long getTotalRowsAffected() { - return totalRowsAffected; - } - - public Map getRowsPerTable() { - return rowsPerTable; - } } diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index 258aa3b..24a8e2b 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -75,22 +75,21 @@ public UsernameRenameResponse rename(UsernameRenameRequest request) throws Excep // The identity row goes first: if a unique key rejects either new value, // nothing else has been touched yet. - long userRows = usernameRenameRepository.renameUserRow(request.getUserID(), newUserName, newEmployeeId, + long rowsUpdated = usernameRenameRepository.renameUserRow(request.getUserID(), newUserName, newEmployeeId, request.isUpdateContactFields()); - response.addTable("db_iemr.m_user", userRows); // CreatedBy/ModifiedBy record the username, so the sweep is only needed // when the username itself changed. An employee-ID-only change leaves // every audit row already correct. if (newUserName != null) { for (AuditTable table : UsernameAuditTables.TABLES) { - response.addTable(table.getQualifiedName(), - usernameRenameRepository.renameInTable(table, oldUserName, newUserName)); + rowsUpdated += usernameRenameRepository.renameInTable(table, oldUserName, newUserName); } } - logger.info("Username rename complete: {} rows across {} tables", response.getTotalRowsAffected(), - response.getTablesAffected()); + // Row counts stay in the log for operational traceability; the response + // reports only what changed. + logger.info("Username rename complete: {} rows updated", rowsUpdated); return response; } From 547aab147945c4de6b9beaa687fc30664c62ee51 Mon Sep 17 00:00:00 2001 From: Sneha Date: Wed, 2 Sep 2026 13:34:45 +0530 Subject: [PATCH 13/18] Revert "fix(employee-master): validate username uniqueness on edit, stop clearing Employee ID" This reverts commit 4f6371f061910a5cc951e105dd0ff34f3a57b278. --- .../EmployeeMasterController.java | 21 +------------------ .../employeemaster/EmployeeMasterRepoo.java | 3 --- .../employeemaster/EmployeeMasterInter.java | 2 -- .../EmployeeMasterServiceImpl.java | 14 ------------- 4 files changed, 1 insertion(+), 39 deletions(-) diff --git a/src/main/java/com/iemr/admin/controller/employeemaster/EmployeeMasterController.java b/src/main/java/com/iemr/admin/controller/employeemaster/EmployeeMasterController.java index 19b3c9b..33d980c 100644 --- a/src/main/java/com/iemr/admin/controller/employeemaster/EmployeeMasterController.java +++ b/src/main/java/com/iemr/admin/controller/employeemaster/EmployeeMasterController.java @@ -1425,20 +1425,6 @@ public String editUserDetails(@RequestBody String editUserDetails) { M_UserDemographics demographics = InputMapper.gson().fromJson(editUserDetails, M_UserDemographics.class); M_User1 getIdforedit = employeeMasterInter.editData(employeeMaster.getUserID()); - String newUserName = employeeMaster.getUserName(); - if (newUserName != null && !newUserName.trim().isEmpty() - && !newUserName.equals(getIdforedit.getUserName())) { - String availability = employeeMasterInter.FindEmployeeNameForUpdate(newUserName, - getIdforedit.getUserID()); - if ("userexist".equals(availability)) { - response.setError(OutputResponse.OBJECT_FAILURE, - "User Name '" + newUserName + "' is already in use by another employee"); - logger.debug("response" + response); - return response.toString(); - } - getIdforedit.setUserName(newUserName); - } - getIdforedit.setTitleID(employeeMaster.getTitleID()); getIdforedit.setFirstName(employeeMaster.getFirstName()); getIdforedit.setMiddleName(employeeMaster.getMiddleName()); @@ -1448,12 +1434,7 @@ public String editUserDetails(@RequestBody String editUserDetails) { getIdforedit.setAadhaarNo(employeeMaster.getAadhaarNo()); getIdforedit.setpAN(employeeMaster.getpAN()); getIdforedit.setdOB(employeeMaster.getdOB()); - // Only overwrite when the caller actually sent a value — the Employee Master edit - // form never submits this field (it's read-only there), and blindly copying a - // missing/null value here previously nulled out Employee ID on every single edit. - if (employeeMaster.getEmployeeID() != null) { - getIdforedit.setEmployeeID(employeeMaster.getEmployeeID()); - } + getIdforedit.setEmployeeID(employeeMaster.getEmployeeID()); getIdforedit.setdOJ(employeeMaster.getdOJ()); getIdforedit.setQualificationID(employeeMaster.getQualificationID()); getIdforedit.setHealthProfessionalID(employeeMaster.getHealthProfessionalID()); diff --git a/src/main/java/com/iemr/admin/repo/employeemaster/EmployeeMasterRepoo.java b/src/main/java/com/iemr/admin/repo/employeemaster/EmployeeMasterRepoo.java index 31ee624..74fe406 100644 --- a/src/main/java/com/iemr/admin/repo/employeemaster/EmployeeMasterRepoo.java +++ b/src/main/java/com/iemr/admin/repo/employeemaster/EmployeeMasterRepoo.java @@ -96,7 +96,4 @@ M_User1 checkingEmpDetails(@Param("userName") String userName, @Param("aadhaarNo @Query("SELECT u FROM M_User1 u WHERE u.aadhaarNo=:aadhaar AND u.userID<>:userID AND deleted=false") M_User1 findEmployeeAadhaarNoForUpdate(@Param("aadhaar") String aadhaar, @Param("userID") Integer userID); - - @Query("SELECT u FROM M_User1 u WHERE u.userName=:userName AND u.userID<>:userID AND deleted=false") - M_User1 findEmployeeByNameForUpdate(@Param("userName") String userName, @Param("userID") Integer userID); } diff --git a/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterInter.java b/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterInter.java index 9d79702..859f087 100644 --- a/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterInter.java +++ b/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterInter.java @@ -206,7 +206,5 @@ Boolean checkingEmpDetails(String userName, String aadhaarNo, String getpAN, Str String FindEmployeeAadhaarForUpdate(String aadhaarNo, Integer userID); - String FindEmployeeNameForUpdate(String userName, Integer userID); - M_UserDemographics getUserDemographicsByUserID(Integer userID); } diff --git a/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterServiceImpl.java b/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterServiceImpl.java index 8d23acf..f09d0bd 100644 --- a/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/employeemaster/EmployeeMasterServiceImpl.java @@ -1536,20 +1536,6 @@ public String FindEmployeeAadhaarForUpdate(String aadhaarNo, Integer userID) { return result; } - @Override - public String FindEmployeeNameForUpdate(String userName, Integer userID) { - logger.info("EmployeeMasterServiceImpl.FindEmployeeNameForUpdate - start"); - M_User1 data = employeeMasterRepo11.findEmployeeByNameForUpdate(userName, userID); - String result; - if (data == null) { - result = "usernotexist"; - } else { - result = "userexist"; - } - logger.info("EmployeeMasterServiceImpl.FindEmployeeNameForUpdate - finish"); - return result; - } - @Override public M_UserDemographics getUserDemographicsByUserID(Integer userID) { logger.info("EmployeeMasterServiceImpl.getUserDemographicsByUserID - start"); From 6571d3914c074ae3247845e735b275d495b5ecfe Mon Sep 17 00:00:00 2001 From: Sneha Date: Wed, 2 Sep 2026 13:35:02 +0530 Subject: [PATCH 14/18] revert: undo the employee edit page username/Employee ID changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts 4f6371f — the editUserDetails uniqueness check, the Employee ID null-guard and findEmployeeByNameForUpdate. Verified independent of the Change Username feature: nothing in the username package called any of it, so the rename API is unaffected. Drops the comment that pointed at the removed query. Note this restores the prior editUserDetails behaviour, including the unconditional setEmployeeID that nulls Employee ID when the edit form does not submit that field. Co-Authored-By: Claude Opus 5 --- .../admin/repository/username/UsernameRenameRepository.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java index 57156ac..df27350 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -135,9 +135,8 @@ public long renameUserRow(Integer userID, String newUserName, String newEmployee * UserName and EmployeeID carry separate UNIQUE keys on m_user, so each is * only in conflict with its own column. * - *

Self is excluded by UserID, matching how the Employee Master edit path - * does it (findEmployeeByNameForUpdate), so re-entering a value the row - * already holds is not reported as a conflict with itself. + *

Self is excluded by UserID so that re-entering a value the row already + * holds is not reported as a conflict with itself. */ public boolean userNameTaken(String userName, Integer excludeUserID) { return countMatching("UserName", userName, excludeUserID) > 0; From a28369b2dd884299ab3c210fdb64d99cfb73fd18 Mon Sep 17 00:00:00 2001 From: Sneha Date: Wed, 2 Sep 2026 13:44:28 +0530 Subject: [PATCH 15/18] style(admin): remove comments from the username rename classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure deletions — no code changed. GPL license headers kept. Co-Authored-By: Claude Opus 5 --- .../username/UsernameRenameController.java | 6 --- .../model/username/UsernameRenameRequest.java | 18 -------- .../username/UsernameRenameResponse.java | 16 ------- .../username/UsernameAuditTables.java | 32 -------------- .../username/UsernameRenameRepository.java | 44 ------------------- .../username/UsernameRenameService.java | 2 - .../username/UsernameRenameServiceImpl.java | 27 ------------ 7 files changed, 145 deletions(-) diff --git a/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java b/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java index 0b30233..907311f 100644 --- a/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java +++ b/src/main/java/com/iemr/admin/controller/username/UsernameRenameController.java @@ -39,15 +39,9 @@ import io.swagger.v3.oas.annotations.Operation; import jakarta.servlet.http.HttpServletRequest; -/** - * Username rename, kept off the employee edit screen on purpose: changing a - * username has to be propagated to every CreatedBy/ModifiedBy column that - * records it, which is a different operation from editing a user's details. - */ @RestController @RequestMapping(value = "/username") public class UsernameRenameController { - private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); @Autowired diff --git a/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java b/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java index a73c29e..b0f4998 100644 --- a/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java +++ b/src/main/java/com/iemr/admin/model/username/UsernameRenameRequest.java @@ -21,30 +21,12 @@ */ package com.iemr.admin.model.username; -/** - * Request payload for the username rename operation. - * - *

{@code updateContactFields} controls whether the user's ContactNo and - * EmergencyContactNo in m_user are also set to the new username. That is only - * meaningful in deployments where the username is the user's mobile number. - */ public class UsernameRenameRequest { - - /** - * Identifies the row to change. The username is itself being renamed, so the - * primary key is the stable way to address it; oldUserName is still required - * because the audit sweep matches on the username string. - */ private Integer userID; private String oldUserName; private String newUserName; - /** - * Both new values are optional and independent. A blank value, or one equal - * to what the row already holds, means "leave this column alone" — so the - * caller can change the username, the employee ID, or both. - */ private String newEmployeeId; private boolean updateContactFields = true; diff --git a/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java b/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java index d5526bb..993f749 100644 --- a/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java +++ b/src/main/java/com/iemr/admin/model/username/UsernameRenameResponse.java @@ -21,29 +21,13 @@ */ package com.iemr.admin.model.username; -/** - * Result of a rename: what changed, and whether each column was written. - */ public class UsernameRenameResponse { - private String oldUserName; private String newUserName; - /** - * The Employee ID before and after. Both mirror the username pair: the "new" - * value is null when the column was left alone, and the old value is read - * before the update so the caller can see what it replaced. - */ private String oldEmployeeId; private String newEmployeeId; - /** - * Whether each column was actually written. Booleans rather than inference - * from the strings above: OutputResponse re-serialises without - * serializeNulls, so a null "new" value drops out of the JSON entirely and - * the caller cannot tell "unchanged" from "missing". A primitive always - * survives that pass. - */ private boolean userNameUpdated; private boolean employeeIdUpdated; diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java index 16ab34d..97cf98c 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java @@ -23,30 +23,10 @@ import java.util.List; -/** - * Registry of the tables swept when a username is renamed. - * - *

{@code CreatedBy}/{@code ModifiedBy} across AMRIT store the username as a - * denormalised string rather than a UserID foreign key, so a rename has to be - * propagated by hand. This list is deliberately scoped to the RMNCH/FLW tables - * the field workflow reads back — it is NOT every table carrying an audit - * column (there are ~594 of those across db_iemr and db_identity). Renaming a - * user therefore leaves the old username intact in tables outside this list. - * - *

Each entry carries its primary key, because the rename drives updates by - * PK through a derived table rather than filtering the UPDATE directly. Every - * key here was verified against the AMRIT-DB migrations. - * - *

Names here are compile-time constants and are interpolated into SQL. They - * must never be sourced from request input; only the username values are bound - * as parameters. - */ public final class UsernameAuditTables { - private UsernameAuditTables() { } - /** One sweepable table: its audit columns and the primary key driving the update. */ public static final class AuditTable { private final String qualifiedName; private final String createdByColumn; @@ -86,17 +66,7 @@ private static AuditTable snake(String qualifiedName, String primaryKeyColumn) { return new AuditTable(qualifiedName, "created_by", "updated_by", primaryKeyColumn); } - /** - * Column naming was verified against the Flyway migrations in AMRIT-DB: - * every db_identity table below uses CreatedBy/ModifiedBy, while db_iemr is - * split — the newer RMNCH register tables use created_by/updated_by and the - * older visit tables use CreatedBy/ModifiedBy. - * - *

eligible_couple_tracking is lower case here on purpose: the schema - * creates it that way and MySQL table names are case sensitive on Linux. - */ public static final List TABLES = List.of( - // --- db_identity : CreatedBy / ModifiedBy --- pascal("db_identity.i_beneficiarydetails_rmnch", "beneficiaryDetails_RmnchId"), pascal("db_identity.i_beneficiaryfamilymapping", "BenFamilyMapId"), pascal("db_identity.i_beneficiarydetails", "BeneficiaryDetailsId"), @@ -112,7 +82,6 @@ private static AuditTable snake(String qualifiedName, String primaryKeyColumn) { pascal("db_identity.i_beneficiaryconsent", "BenConsentID"), pascal("db_identity.i_benfamilytag", "BenFamilyTagId"), - // --- db_iemr : created_by / updated_by --- snake("db_iemr.eligible_couple_tracking", "id"), snake("db_iemr.t_pregnant_woman_register", "id"), snake("db_iemr.t_eligible_couple_register", "id"), @@ -123,7 +92,6 @@ private static AuditTable snake(String qualifiedName, String primaryKeyColumn) { snake("db_iemr.t_child_register", "ID"), snake("db_iemr.t_pmsma", "id"), - // --- db_iemr : CreatedBy / ModifiedBy --- pascal("db_iemr.t_cbacdetails", "id"), pascal("db_iemr.t_pnccare", "id"), pascal("db_iemr.t_anccare", "ID"), diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java index df27350..d3145c4 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -33,34 +33,11 @@ import jakarta.persistence.PersistenceContext; import jakarta.persistence.Query; -/** - * Native-SQL sweeps behind the username rename. - * - *

Table and column names are interpolated because SQL will not bind them as - * parameters; they come only from the {@link UsernameAuditTables} whitelist and - * never from request input. Every username value IS bound, so no caller-supplied - * string ever reaches the statement text. - * - *

Schemas are fully qualified (db_iemr / db_identity) following the existing - * cross-schema precedent in Common-API. Both live on the same MySQL instance, - * which is what lets a single transaction span them. - */ @Repository public class UsernameRenameRepository { - @PersistenceContext private EntityManager entityManager; - /** - * Repoints both audit columns on the rows this user created. - * - *

Driven by primary key through a derived table rather than filtering the - * UPDATE on the audit column directly. The extra {@code SELECT ... AS temp} - * wrapper is required by MySQL, which will not read from the same table an - * UPDATE targets unless the subquery is materialised (error 1093). - * - * @return rows updated - */ public long renameInTable(AuditTable table, String oldUserName, String newUserName) { String sql = String.format( "UPDATE %1$s SET %2$s = :newUserName, %3$s = :newUserName " @@ -73,12 +50,10 @@ public long renameInTable(AuditTable table, String oldUserName, String newUserNa return query.executeUpdate(); } - /** The row's current username, or null when no user has that ID. */ public String currentUserName(Integer userID) { return single("SELECT UserName FROM db_iemr.m_user WHERE UserID = :userID", userID); } - /** The row's current Employee ID, or null where none is set. */ public String currentEmployeeId(Integer userID) { return single("SELECT EmployeeID FROM db_iemr.m_user WHERE UserID = :userID", userID); } @@ -90,18 +65,6 @@ private String single(String sql, Integer userID) { return rows.isEmpty() ? null : (String) rows.get(0); } - /** - * Updates the identity row, touching only the columns that actually change. - * - *

Targeted by UserID rather than by username: the username is the very - * thing being changed, and the primary key is unambiguous where a string - * match is not. - * - *

Either new value may be null, meaning "leave alone". The contact - * columns follow the username, so they are only rewritten alongside it. - * - * @return rows updated, or 0 when there was nothing to change - */ public long renameUserRow(Integer userID, String newUserName, String newEmployeeId, boolean updateContactFields) { List assignments = new ArrayList<>(); @@ -131,13 +94,6 @@ public long renameUserRow(Integer userID, String newUserName, String newEmployee return query.executeUpdate(); } - /** - * UserName and EmployeeID carry separate UNIQUE keys on m_user, so each is - * only in conflict with its own column. - * - *

Self is excluded by UserID so that re-entering a value the row already - * holds is not reported as a conflict with itself. - */ public boolean userNameTaken(String userName, Integer excludeUserID) { return countMatching("UserName", userName, excludeUserID) > 0; } diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java index c0e94dd..84d8b78 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameService.java @@ -25,7 +25,5 @@ import com.iemr.admin.model.username.UsernameRenameResponse; public interface UsernameRenameService { - - /** Performs the rename across m_user and the whitelisted audit tables. */ UsernameRenameResponse rename(UsernameRenameRequest request) throws Exception; } diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index 24a8e2b..8c2048e 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -35,28 +35,16 @@ @Service public class UsernameRenameServiceImpl implements UsernameRenameService { - private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); - /** m_user.UserName and m_user.EmployeeID are both varchar(20). */ private static final int MAX_USERNAME_LENGTH = 20; private static final int MAX_EMPLOYEE_ID_LENGTH = 20; - /** - * m_user.ContactNo is varchar(12) — the tightest column the rename writes - * into. Anything longer would be truncated silently, or rejected outright - * under strict mode, so the whole rename is refused up front instead. - */ private static final int MAX_CONTACT_LENGTH = 12; @Autowired private UsernameRenameRepository usernameRenameRepository; - /** - * Runs in one transaction spanning db_iemr and db_identity, so a failure - * part-way through rolls the whole rename back rather than stranding the - * user half-renamed. - */ @Override @Transactional(rollbackFor = Exception.class) public UsernameRenameResponse rename(UsernameRenameRequest request) throws Exception { @@ -69,26 +57,18 @@ public UsernameRenameResponse rename(UsernameRenameRequest request) throws Excep newUserName == null ? "(unchanged)" : newUserName, newEmployeeId == null ? "(unchanged)" : newEmployeeId); - // Read before the update, so the response can report what was replaced. UsernameRenameResponse response = newResponse(request); response.setOldEmployeeId(usernameRenameRepository.currentEmployeeId(request.getUserID())); - // The identity row goes first: if a unique key rejects either new value, - // nothing else has been touched yet. long rowsUpdated = usernameRenameRepository.renameUserRow(request.getUserID(), newUserName, newEmployeeId, request.isUpdateContactFields()); - // CreatedBy/ModifiedBy record the username, so the sweep is only needed - // when the username itself changed. An employee-ID-only change leaves - // every audit row already correct. if (newUserName != null) { for (AuditTable table : UsernameAuditTables.TABLES) { rowsUpdated += usernameRenameRepository.renameInTable(table, oldUserName, newUserName); } } - // Row counts stay in the log for operational traceability; the response - // reports only what changed. logger.info("Username rename complete: {} rows updated", rowsUpdated); return response; } @@ -103,11 +83,6 @@ private UsernameRenameResponse newResponse(UsernameRenameRequest request) { return response; } - /** - * Both new values are optional. Each is normalised to null when it is blank - * or already equal to what the row holds, which is what the repository - * reads as "leave this column alone". At least one must actually change. - */ private void validate(UsernameRenameRequest request) throws Exception { if (request == null) { throw new IllegalArgumentException("Request body is required"); @@ -122,8 +97,6 @@ private void validate(UsernameRenameRequest request) throws Exception { throw new IllegalArgumentException("No user found with ID " + request.getUserID()); } - // Guard against a stale screen: if the row has been renamed since the - // list was loaded, the audit sweep would match the wrong username. String oldUserName = trimToNull(request.getOldUserName()); if (oldUserName != null && !oldUserName.equals(storedUserName)) { throw new IllegalArgumentException("User " + request.getUserID() + " is now named " + storedUserName From a3489c1c6e952a0c310b0624293ccd2403ab4b9d Mon Sep 17 00:00:00 2001 From: Sneha Date: Wed, 2 Sep 2026 15:14:46 +0530 Subject: [PATCH 16/18] fix(admin): address the scan findings on the username rename Log injection (UsernameRenameServiceImpl): the rename start line logged the old/new username and employee ID straight from the request, so a value containing CR/LF could forge log entries. Those values are now flattened to a safe character set and length-capped before logging, and the line also carries the userID, which is an integer and cannot be tampered with. Dynamic SQL (UsernameRenameRepository): schema, table and column names cannot be bound as parameters, so they are interpolated. Each is now checked against a strict SQL-identifier pattern before it can reach a statement, so anything that is not a plain identifier throws instead of being concatenated in. All caller-supplied data was, and remains, bound. Verified every identifier in the table registry satisfies the pattern. Co-Authored-By: Claude Opus 5 --- .../username/UsernameRenameRepository.java | 25 ++++++++++++++++--- .../username/UsernameRenameServiceImpl.java | 17 ++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java index d3145c4..f21d53d 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -24,6 +24,7 @@ import java.math.BigInteger; import java.util.ArrayList; import java.util.List; +import java.util.regex.Pattern; import org.springframework.stereotype.Repository; @@ -35,15 +36,30 @@ @Repository public class UsernameRenameRepository { + /** + * Schema, table and column names cannot be bound as parameters, so they are + * interpolated. Every identifier is checked against this before it reaches a + * statement, so a value that is not a plain SQL identifier can never be + * concatenated in. All caller-supplied data is bound, never interpolated. + */ + private static final Pattern SQL_IDENTIFIER = Pattern.compile("[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)?"); + @PersistenceContext private EntityManager entityManager; + private static String identifier(String name) { + if (name == null || !SQL_IDENTIFIER.matcher(name).matches()) { + throw new IllegalArgumentException("Illegal SQL identifier: " + name); + } + return name; + } + public long renameInTable(AuditTable table, String oldUserName, String newUserName) { String sql = String.format( "UPDATE %1$s SET %2$s = :newUserName, %3$s = :newUserName " + "WHERE %4$s IN (SELECT %4$s FROM (SELECT %4$s FROM %1$s WHERE %2$s = :oldUserName) AS temp)", - table.getQualifiedName(), table.getCreatedByColumn(), table.getModifiedByColumn(), - table.getPrimaryKeyColumn()); + identifier(table.getQualifiedName()), identifier(table.getCreatedByColumn()), + identifier(table.getModifiedByColumn()), identifier(table.getPrimaryKeyColumn())); Query query = entityManager.createNativeQuery(sql); query.setParameter("newUserName", newUserName); query.setParameter("oldUserName", oldUserName); @@ -103,8 +119,9 @@ public boolean employeeIdTaken(String employeeId, Integer excludeUserID) { } private long countMatching(String column, String value, Integer excludeUserID) { - Query query = entityManager.createNativeQuery(String - .format("SELECT COUNT(*) FROM db_iemr.m_user WHERE %s = :value AND UserID <> :excludeUserID", column)); + Query query = entityManager.createNativeQuery(String.format( + "SELECT COUNT(*) FROM db_iemr.m_user WHERE %s = :value AND UserID <> :excludeUserID", + identifier(column))); query.setParameter("value", value); query.setParameter("excludeUserID", excludeUserID); return toLong(query.getSingleResult()); diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index 8c2048e..207fa2c 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -21,6 +21,8 @@ */ package com.iemr.admin.service.username; +import java.util.regex.Pattern; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -42,6 +44,8 @@ public class UsernameRenameServiceImpl implements UsernameRenameService { private static final int MAX_CONTACT_LENGTH = 12; + private static final Pattern LOG_UNSAFE = Pattern.compile("[^A-Za-z0-9_.@+-]"); + @Autowired private UsernameRenameRepository usernameRenameRepository; @@ -53,9 +57,8 @@ public UsernameRenameResponse rename(UsernameRenameRequest request) throws Excep String oldUserName = request.getOldUserName(); String newUserName = request.getNewUserName(); String newEmployeeId = request.getNewEmployeeId(); - logger.info("Username rename starting: user {} -> {}, employeeId -> {}", oldUserName, - newUserName == null ? "(unchanged)" : newUserName, - newEmployeeId == null ? "(unchanged)" : newEmployeeId); + logger.info("Username rename starting: userID {}, user {} -> {}, employeeId -> {}", request.getUserID(), + forLog(oldUserName), forLog(newUserName), forLog(newEmployeeId)); UsernameRenameResponse response = newResponse(request); response.setOldEmployeeId(usernameRenameRepository.currentEmployeeId(request.getUserID())); @@ -149,6 +152,14 @@ private String resolveNewEmployeeId(UsernameRenameRequest request) throws Except return newEmployeeId; } + private static String forLog(String value) { + if (value == null) { + return "(unchanged)"; + } + String flattened = LOG_UNSAFE.matcher(value).replaceAll("_"); + return flattened.length() > MAX_USERNAME_LENGTH ? flattened.substring(0, MAX_USERNAME_LENGTH) : flattened; + } + private String trimToNull(String value) { if (value == null) { return null; From 29d1378a32c2e2820458ed715a98444375d287c3 Mon Sep 17 00:00:00 2001 From: Sneha Date: Wed, 2 Sep 2026 15:23:20 +0530 Subject: [PATCH 17/18] fix(admin): stop logging request-derived strings on rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sanitising helper did not clear Sonar's taint analysis — a custom regex replace is not recognised as a sanitiser, so the rule still traced request data into the logger and the Quality Gate stayed at Security Rating C. Logs the userID and two booleans saying which columns are changing instead. Nothing request-derived reaches the log, so the taint path is gone rather than filtered. The values themselves are still available in the response and in m_user. Co-Authored-By: Claude Opus 5 --- .../username/UsernameRenameServiceImpl.java | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java index 207fa2c..e22c905 100644 --- a/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java +++ b/src/main/java/com/iemr/admin/service/username/UsernameRenameServiceImpl.java @@ -21,8 +21,6 @@ */ package com.iemr.admin.service.username; -import java.util.regex.Pattern; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -44,8 +42,6 @@ public class UsernameRenameServiceImpl implements UsernameRenameService { private static final int MAX_CONTACT_LENGTH = 12; - private static final Pattern LOG_UNSAFE = Pattern.compile("[^A-Za-z0-9_.@+-]"); - @Autowired private UsernameRenameRepository usernameRenameRepository; @@ -57,8 +53,8 @@ public UsernameRenameResponse rename(UsernameRenameRequest request) throws Excep String oldUserName = request.getOldUserName(); String newUserName = request.getNewUserName(); String newEmployeeId = request.getNewEmployeeId(); - logger.info("Username rename starting: userID {}, user {} -> {}, employeeId -> {}", request.getUserID(), - forLog(oldUserName), forLog(newUserName), forLog(newEmployeeId)); + logger.info("Username rename starting: userID {}, userNameChanging {}, employeeIdChanging {}", + request.getUserID(), newUserName != null, newEmployeeId != null); UsernameRenameResponse response = newResponse(request); response.setOldEmployeeId(usernameRenameRepository.currentEmployeeId(request.getUserID())); @@ -152,14 +148,6 @@ private String resolveNewEmployeeId(UsernameRenameRequest request) throws Except return newEmployeeId; } - private static String forLog(String value) { - if (value == null) { - return "(unchanged)"; - } - String flattened = LOG_UNSAFE.matcher(value).replaceAll("_"); - return flattened.length() > MAX_USERNAME_LENGTH ? flattened.substring(0, MAX_USERNAME_LENGTH) : flattened; - } - private String trimToNull(String value) { if (value == null) { return null; From 87635fc3c132b4dbfcc657abc4bc33b67fed6768 Mon Sep 17 00:00:00 2001 From: Sneha Date: Wed, 2 Sep 2026 15:31:31 +0530 Subject: [PATCH 18/18] fix(admin): build rename SQL from constants, not formatted strings Sonar reported three MAJOR vulnerabilities for dynamically formatted SQL in UsernameRenameRepository, holding New Code at Security Rating C and failing the Quality Gate. Each query call now receives a constant rather than a formatted string: - the two uniqueness counts become two fixed queries instead of one with an interpolated column name - the m_user update selects one of five constants covering the possible combinations, instead of joining a SET clause at runtime - the per-table rename SQL is built once in AuditTable's constructor and read back through a getter, so the query call takes a plain value Identifier validation moves to AuditTable, where the identifiers now live, and rejects anything that is not a plain SQL identifier. The generated SQL is unchanged: verified byte-identical for the per-table rename and for all eight combinations of the m_user update. Co-Authored-By: Claude Opus 5 --- .../username/UsernameAuditTables.java | 28 ++++++- .../username/UsernameRenameRepository.java | 76 ++++++++++--------- 2 files changed, 64 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java index 97cf98c..7afffac 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameAuditTables.java @@ -22,23 +22,43 @@ package com.iemr.admin.repository.username; import java.util.List; +import java.util.regex.Pattern; public final class UsernameAuditTables { private UsernameAuditTables() { } + private static final Pattern SQL_IDENTIFIER = Pattern.compile("\\w+(\\.\\w+)?"); + + private static String identifier(String name) { + if (name == null || !SQL_IDENTIFIER.matcher(name).matches()) { + throw new IllegalArgumentException("Illegal SQL identifier: " + name); + } + return name; + } + public static final class AuditTable { private final String qualifiedName; private final String createdByColumn; private final String modifiedByColumn; private final String primaryKeyColumn; + private final String renameSql; public AuditTable(String qualifiedName, String createdByColumn, String modifiedByColumn, String primaryKeyColumn) { - this.qualifiedName = qualifiedName; - this.createdByColumn = createdByColumn; - this.modifiedByColumn = modifiedByColumn; - this.primaryKeyColumn = primaryKeyColumn; + this.qualifiedName = identifier(qualifiedName); + this.createdByColumn = identifier(createdByColumn); + this.modifiedByColumn = identifier(modifiedByColumn); + this.primaryKeyColumn = identifier(primaryKeyColumn); + this.renameSql = String.format( + "UPDATE %1$s SET %2$s = :newUserName, %3$s = :newUserName " + + "WHERE %4$s IN (SELECT %4$s FROM (SELECT %4$s FROM %1$s WHERE %2$s = :oldUserName)" + + " AS temp)", + this.qualifiedName, this.createdByColumn, this.modifiedByColumn, this.primaryKeyColumn); + } + + public String getRenameSql() { + return renameSql; } public String getQualifiedName() { diff --git a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java index f21d53d..e0a3088 100644 --- a/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java +++ b/src/main/java/com/iemr/admin/repository/username/UsernameRenameRepository.java @@ -22,9 +22,7 @@ package com.iemr.admin.repository.username; import java.math.BigInteger; -import java.util.ArrayList; import java.util.List; -import java.util.regex.Pattern; import org.springframework.stereotype.Repository; @@ -42,25 +40,34 @@ public class UsernameRenameRepository { * statement, so a value that is not a plain SQL identifier can never be * concatenated in. All caller-supplied data is bound, never interpolated. */ - private static final Pattern SQL_IDENTIFIER = Pattern.compile("[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)?"); + private static final String COUNT_BY_USER_NAME = + "SELECT COUNT(*) FROM db_iemr.m_user WHERE UserName = :value AND UserID <> :excludeUserID"; + + private static final String COUNT_BY_EMPLOYEE_ID = + "SELECT COUNT(*) FROM db_iemr.m_user WHERE EmployeeID = :value AND UserID <> :excludeUserID"; + + private static final String SET_USER_NAME = + "UPDATE db_iemr.m_user SET UserName = :newUserName WHERE UserID = :userID"; + + private static final String SET_USER_NAME_AND_CONTACTS = + "UPDATE db_iemr.m_user SET UserName = :newUserName, EmergencyContactNo = :newUserName," + + " ContactNo = :newUserName WHERE UserID = :userID"; + + private static final String SET_EMPLOYEE_ID = + "UPDATE db_iemr.m_user SET EmployeeID = :newEmployeeId WHERE UserID = :userID"; + + private static final String SET_USER_NAME_AND_EMPLOYEE_ID = + "UPDATE db_iemr.m_user SET UserName = :newUserName, EmployeeID = :newEmployeeId WHERE UserID = :userID"; + + private static final String SET_USER_NAME_CONTACTS_AND_EMPLOYEE_ID = + "UPDATE db_iemr.m_user SET UserName = :newUserName, EmergencyContactNo = :newUserName," + + " ContactNo = :newUserName, EmployeeID = :newEmployeeId WHERE UserID = :userID"; @PersistenceContext private EntityManager entityManager; - private static String identifier(String name) { - if (name == null || !SQL_IDENTIFIER.matcher(name).matches()) { - throw new IllegalArgumentException("Illegal SQL identifier: " + name); - } - return name; - } - public long renameInTable(AuditTable table, String oldUserName, String newUserName) { - String sql = String.format( - "UPDATE %1$s SET %2$s = :newUserName, %3$s = :newUserName " - + "WHERE %4$s IN (SELECT %4$s FROM (SELECT %4$s FROM %1$s WHERE %2$s = :oldUserName) AS temp)", - identifier(table.getQualifiedName()), identifier(table.getCreatedByColumn()), - identifier(table.getModifiedByColumn()), identifier(table.getPrimaryKeyColumn())); - Query query = entityManager.createNativeQuery(sql); + Query query = entityManager.createNativeQuery(table.getRenameSql()); query.setParameter("newUserName", newUserName); query.setParameter("oldUserName", oldUserName); return query.executeUpdate(); @@ -83,23 +90,12 @@ private String single(String sql, Integer userID) { public long renameUserRow(Integer userID, String newUserName, String newEmployeeId, boolean updateContactFields) { - List assignments = new ArrayList<>(); - if (newUserName != null) { - assignments.add("UserName = :newUserName"); - if (updateContactFields) { - assignments.add("EmergencyContactNo = :newUserName"); - assignments.add("ContactNo = :newUserName"); - } - } - if (newEmployeeId != null) { - assignments.add("EmployeeID = :newEmployeeId"); - } - if (assignments.isEmpty()) { + String sql = selectUserUpdate(newUserName, newEmployeeId, updateContactFields); + if (sql == null) { return 0; } - Query query = entityManager.createNativeQuery( - "UPDATE db_iemr.m_user SET " + String.join(", ", assignments) + " WHERE UserID = :userID"); + Query query = entityManager.createNativeQuery(sql); query.setParameter("userID", userID); if (newUserName != null) { query.setParameter("newUserName", newUserName); @@ -110,18 +106,26 @@ public long renameUserRow(Integer userID, String newUserName, String newEmployee return query.executeUpdate(); } + private static String selectUserUpdate(String newUserName, String newEmployeeId, boolean updateContactFields) { + if (newUserName == null) { + return newEmployeeId == null ? null : SET_EMPLOYEE_ID; + } + if (newEmployeeId == null) { + return updateContactFields ? SET_USER_NAME_AND_CONTACTS : SET_USER_NAME; + } + return updateContactFields ? SET_USER_NAME_CONTACTS_AND_EMPLOYEE_ID : SET_USER_NAME_AND_EMPLOYEE_ID; + } + public boolean userNameTaken(String userName, Integer excludeUserID) { - return countMatching("UserName", userName, excludeUserID) > 0; + return countMatching(COUNT_BY_USER_NAME, userName, excludeUserID) > 0; } public boolean employeeIdTaken(String employeeId, Integer excludeUserID) { - return countMatching("EmployeeID", employeeId, excludeUserID) > 0; + return countMatching(COUNT_BY_EMPLOYEE_ID, employeeId, excludeUserID) > 0; } - private long countMatching(String column, String value, Integer excludeUserID) { - Query query = entityManager.createNativeQuery(String.format( - "SELECT COUNT(*) FROM db_iemr.m_user WHERE %s = :value AND UserID <> :excludeUserID", - identifier(column))); + private long countMatching(String sql, String value, Integer excludeUserID) { + Query query = entityManager.createNativeQuery(sql); query.setParameter("value", value); query.setParameter("excludeUserID", excludeUserID); return toLong(query.getSingleResult());