From 10d4a08e0e18e0b12178b1088af6e35b8ed40298 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 4 Sep 2026 12:48:44 +0530 Subject: [PATCH 1/5] fix: normalize roleID types before diffing existing vs new roles existingRoleIDs (from group.roles) and newRoleIDs (from roleIDs_duringEdit) were compared with plain Array.includes() with no type coercion. When one source returns roleID as a numeric string and the other as a number, an already-existing role fails the match and gets bucketed into both rolesToAdd and rolesToRemove, firing simultaneous create+delete calls that race each other and can silently drop a role (e.g. Counsellor) on update. Co-Authored-By: Claude Sonnet 5 --- .../work-location-mapping.component.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts index 8113a2f..53ab7b8 100644 --- a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts +++ b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts @@ -5321,10 +5321,12 @@ export class WorkLocationMappingComponent ...new Set( group.roles .filter((r) => !r.userServciceRoleDeleted) - .map((r) => r.roleID), + .map((r) => Number(r.roleID)), ), ]; - const newRoleIDs: number[] = this.roleIDs_duringEdit; + const newRoleIDs: number[] = (this.roleIDs_duringEdit || []).map( + (rid: any) => Number(rid), + ); const rolesToAdd = newRoleIDs.filter( (rid) => !existingRoleIDs.includes(rid), From be4366d09f10de891bc6b46eefb1b8546e1bd76b Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 4 Sep 2026 13:14:00 +0530 Subject: [PATCH 2/5] fix: batch newly-added roles into a single SaveWorkLocationMapping call rolesToAdd previously fired one SaveWorkLocationMapping POST per role in parallel via forkJoin. The backend treats a create as the sole active role for the user+serviceline+block scope, so separate parallel creates raced each other and only the last commit stayed active - dropping roles added alongside others (reproduced with StopTB Counsellor + Nurse, where Nurse's row was born already deleted). Batch all rolesToAdd into one call instead, with every new role in the previleges[0].ID array - the same shape already used when two roles are added together via Create New Mapping, which works correctly. Location fields (incl. StopTB's nikshayTUID/nikshayFacilityID) are per-group, not per-role, so batching does not change what is sent for them. Co-Authored-By: Claude Sonnet 5 --- .../work-location-mapping.component.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts index 53ab7b8..e0ae979 100644 --- a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts +++ b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts @@ -5477,14 +5477,22 @@ export class WorkLocationMappingComponent } } - // CREATE new rows for added roles - for (const rid of rolesToAdd) { - const roleName = this.getRoleNameById(rid); + // CREATE new rows for added roles. + // Batched into a single SaveWorkLocationMapping call (one previleges + // entry, ID array holding every added role) instead of one call per + // role. Firing them separately let the backend treat each create as + // the sole active role for this user+serviceline+block, so parallel + // creates raced and only the last commit stayed active — dropping + // roles added alongside others (reproduced with StopTB Counsellor/ + // Nurse). Location fields (incl. StopTB's nikshayTUID/nikshayFacilityID) + // are per-group, not per-role, so batching them here is unchanged. + if (rolesToAdd.length > 0) { const newObj: any = { previleges: [ { - ID: [ - { + ID: rolesToAdd.map((rid) => { + const roleName = this.getRoleNameById(rid); + return { roleID: rid, teleConsultation: group.serviceName === 'HWC' && @@ -5502,8 +5510,8 @@ export class WorkLocationMappingComponent roleName?.toLowerCase() !== 'supervisor' ? this.isOutboundEdit : null, - }, - ], + }; + }), providerServiceMapID: this.providerServiceMapID_duringEdit, workingLocationID: this.isFacilityServicelineEdit ? null From 0e714698f33c31ade8b1e38e2eb2e55d8752bb04 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 4 Sep 2026 13:21:40 +0530 Subject: [PATCH 3/5] test: cover role add/keep/remove diffing in updateGroupedWorkLocation - newly-added roles (e.g. Nurse + Counsellor) must go out in a single SaveWorkLocationMapping call, not one per role - an existing roleID stored as a numeric string must be treated as equal to the same roleID selected as a number, so it is kept in-place rather than spuriously added and removed at once Regression coverage for the StopTB Counsellor/Nurse role-dropping bug fixed in the previous two commits. Co-Authored-By: Claude Sonnet 5 --- .../work-location-mapping.component.spec.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.spec.ts b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.spec.ts index 9f935f4..ad75c8d 100644 --- a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.spec.ts +++ b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.spec.ts @@ -41,6 +41,10 @@ const FakeWorkLocationMapping = { getServices: (_userID: any) => of({ data: [] }), getMappedWorkLocationList: (_serviceProviderID: any) => of({ data: [] }), getUserName: (_serviceProviderID: any) => of({ data: [] }), + SaveWorkLocationMapping: (_data: any) => of({}), + UpdateWorkLocationMapping: (_data: any) => of({}), + DeleteWorkLocationMapping: (_data: any) => of({}), + DeleteWorkLocationMappingForTM: (_data: any) => of({}), }; const FakeVillageMasterService = {}; @@ -192,4 +196,91 @@ describe('WorkLocationMappingComponent', () => { expect(saved.villageName).toEqual(['Alamkeri']); }); }); + + // Regression coverage for the StopTB "Counsellor/Nurse silently + // dropped on Update" bug: (1) existingRoleIDs/newRoleIDs must compare + // equal regardless of which side is a numeric string, and (2) every + // newly-added role must go out in one SaveWorkLocationMapping call, + // not one racing call per role. + describe('updateGroupedWorkLocation — role add/keep/remove diffing', () => { + initTestBed(); + + const baseGroup = (roles: any[]): any => ({ + userID: 4426, + serviceID: 11, + serviceName: 'Stop TB', + roles, + }); + + beforeEach(() => { + component.RolesList = [ + { roleID: 168, roleName: 'Registration Officer' }, + { roleID: 169, roleName: 'Nurse' }, + { roleID: 170, roleName: 'Counsellor' }, + ]; + component.userID_duringEdit = 4426; + component.providerServiceMapID_duringEdit = 1734; + component.stateID_duringEdit = 2; + component.district_duringEdit = 1; + component.workLocationID_duringEdit = null; + component.isFacilityServicelineEdit = false; + component.isStopTBServicelineEdit = false; + component.editIsAshaSupervisor = false; + component.selectedNikshayTUs = []; + component.selectedNikshayFacilities = []; + component.selectedNikshayVillages = []; + component.selectedNikshayBlock = null; + component.serviceEditvillage = []; + component.editVillageArr = []; + }); + + it('batches two newly-added roles (Nurse + Counsellor) into a single SaveWorkLocationMapping call', () => { + const service = TestBed.inject(WorkLocationMapping); + const saveSpy = spyOn(service, 'SaveWorkLocationMapping').and.returnValue( + of({}), + ); + + component.editGroupedElement = baseGroup([]); + component.roleIDs_duringEdit = [169, 170]; + + component.updateGroupedWorkLocation({}); + + expect(saveSpy).toHaveBeenCalledTimes(1); + const [payload] = saveSpy.calls.mostRecent().args; + const sentRoleIDs = payload[0].previleges[0].ID.map((r: any) => r.roleID); + expect(sentRoleIDs).toEqual([169, 170]); + }); + + it('treats a numeric-string existing roleID as equal to a numeric selected roleID (no spurious add+remove)', () => { + const service = TestBed.inject(WorkLocationMapping); + const saveSpy = spyOn(service, 'SaveWorkLocationMapping').and.returnValue( + of({}), + ); + const updateSpy = spyOn( + service, + 'UpdateWorkLocationMapping', + ).and.returnValue(of({})); + const deleteSpy = spyOn( + service, + 'DeleteWorkLocationMapping', + ).and.returnValue(of({})); + + // Registration Officer's roleID arrives as a string from the + // work-location list endpoint; the checkbox sends it back as a number. + component.editGroupedElement = baseGroup([ + { + roleID: '168' as any, + uSRMappingID: 5907, + userServciceRoleDeleted: false, + }, + ]); + component.roleIDs_duringEdit = [168]; + + component.updateGroupedWorkLocation({}); + + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(saveSpy).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + }); }); From bc02af2dedc85d5df4bae7ed9a925fbaba52ddcf Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 4 Sep 2026 14:27:54 +0530 Subject: [PATCH 4/5] fix: reactivate soft-deleted role on re-add; group Stop TB rows like HWC/FLW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes to updateGroupedWorkLocation()'s edit flow, both scoped to make the Select Role checkboxes behave as the single source of truth: checked = active, unchecked = soft-deleted, and re-checking a previously-removed role reactivates it rather than piling up duplicates. 1. Stop TB rows are now grouped by user+TU (Nikshay block), same as HWC/FLW already are, so multiple roles for one user+location show as one row with multiple role chips instead of a separate row per role. This also removes the risk of an edit on one role's row reaching into an unrelated sibling row's role, since everything for that user+location is already one group. 2. A role in rolesToAdd that has an old soft-deleted row (group.roles keeps deleted entries; only existingRoleIDs filters them out) is now reactivated in place via the same {uSRMappingID, deleted:false} call the existing Activate button already uses, with its location/role fields refreshed to the current edit form — instead of always creating a brand-new duplicate row. Only roles with no prior row at all go through the batched create path added in the previous fix. Co-Authored-By: Claude Sonnet 5 --- .../work-location-mapping.component.ts | 140 ++++++++++++------ 1 file changed, 96 insertions(+), 44 deletions(-) diff --git a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts index e0ae979..11445fb 100644 --- a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts +++ b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts @@ -1072,10 +1072,15 @@ export class WorkLocationMappingComponent const groupMap = new Map(); for (const row of flatRows) { - // Group rows for HWC/FLW service lines (including ASHA Supervisor) by user+block - // Other services get one row per entry + // Group rows for HWC/FLW (including ASHA Supervisor) and Stop TB by + // user+block (Stop TB's "block" is the Nikshay TU — see blockIDToUse + // in updateGroupedWorkLocation) so multiple roles for the same + // user+location show as one row with multiple role chips, instead of + // a separate row per role. Other services still get one row per entry. const isFacilityService = - row.serviceName === 'HWC' || row.serviceName === 'FLW'; + row.serviceName === 'HWC' || + row.serviceName === 'FLW' || + row.serviceName === 'Stop TB'; let key: string; if (isFacilityService) { key = [ @@ -5410,51 +5415,59 @@ export class WorkLocationMappingComponent ? nikshayFacilityIDArr.join(',') : null; + // Shared by "keep" (role stays checked) and "reactivate" (role was + // unchecked before, is checked again now) — both are an in-place + // update of an existing uSRMappingID, refreshed to the edit form's + // current location/role fields. + const buildRoleUpdateObj = (rid: number, uSRMappingID: any) => { + const roleName = this.getRoleNameById(rid); + const updateObj: any = { + uSRMappingID, + userID: this.userID_duringEdit, + roleID: rid, + providerServiceMapID: this.providerServiceMapID_duringEdit, + workingLocationID: this.isFacilityServicelineEdit + ? null + : this.workLocationID_duringEdit, + stateID: this.stateID_duringEdit, + districtID: this.district_duringEdit, + blockID: blockIDToUse, + blockName: blockNameToUse, + villageID: villageIDToUse, + villageName: villageNameToUse, + modifiedBy: this.createdBy, + }; + if (this.isStopTBServicelineEdit) { + updateObj.nikshayTUID = nikshayTUIDToSend; + updateObj.nikshayFacilityID = nikshayFacilityIDToSend; + } + if (group.serviceName === '1097') { + updateObj.inbound = + roleName?.toLowerCase() === 'supervisor' ? false : this.isInboundEdit; + updateObj.outbound = + roleName?.toLowerCase() === 'supervisor' + ? false + : this.isOutboundEdit; + } + if (group.serviceName === 'HWC') { + updateObj.teleConsultation = this.teleConsultationEdit; + } + if (this.isFacilityServicelineEdit && this.editFacilityMappingData) { + updateObj.facilityID = this.editFacilityMappingData.facilityID; + } + return updateObj; + }; + // UPDATE existing rows for kept roles for (const rid of rolesToKeep) { const existingEntry = group.roles.find( (r) => r.roleID === rid && !r.userServciceRoleDeleted, ); if (existingEntry) { - const roleName = this.getRoleNameById(rid); - const updateObj: any = { - uSRMappingID: existingEntry.uSRMappingID, - userID: this.userID_duringEdit, - roleID: rid, - providerServiceMapID: this.providerServiceMapID_duringEdit, - workingLocationID: this.isFacilityServicelineEdit - ? null - : this.workLocationID_duringEdit, - stateID: this.stateID_duringEdit, - districtID: this.district_duringEdit, - blockID: blockIDToUse, - blockName: blockNameToUse, - villageID: villageIDToUse, - villageName: villageNameToUse, - modifiedBy: this.createdBy, - }; - if (this.isStopTBServicelineEdit) { - updateObj.nikshayTUID = nikshayTUIDToSend; - updateObj.nikshayFacilityID = nikshayFacilityIDToSend; - } - if (group.serviceName === '1097') { - updateObj.inbound = - roleName?.toLowerCase() === 'supervisor' - ? false - : this.isInboundEdit; - updateObj.outbound = - roleName?.toLowerCase() === 'supervisor' - ? false - : this.isOutboundEdit; - } - if (group.serviceName === 'HWC') { - updateObj.teleConsultation = this.teleConsultationEdit; - } - if (this.isFacilityServicelineEdit && this.editFacilityMappingData) { - updateObj.facilityID = this.editFacilityMappingData.facilityID; - } allRequests.push( - this.worklocationmapping.UpdateWorkLocationMapping(updateObj), + this.worklocationmapping.UpdateWorkLocationMapping( + buildRoleUpdateObj(rid, existingEntry.uSRMappingID), + ), ); } } @@ -5477,7 +5490,46 @@ export class WorkLocationMappingComponent } } - // CREATE new rows for added roles. + // A checked role with no active row may still have an old soft-deleted + // row from a previous remove (group.roles keeps deleted entries too — + // only existingRoleIDs filters them out). Reactivate that row in place + // (same {uSRMappingID, deleted:false} call the Activate button already + // uses) instead of creating a duplicate — otherwise every remove-then- + // re-add cycle leaves another dead row behind for the same role. + const rolesToReactivate = rolesToAdd.filter((rid) => + group.roles.some((r) => r.roleID === rid && r.userServciceRoleDeleted), + ); + const rolesToCreateFresh = rolesToAdd.filter( + (rid) => !rolesToReactivate.includes(rid), + ); + + for (const rid of rolesToReactivate) { + const deadEntry = group.roles.find( + (r) => r.roleID === rid && r.userServciceRoleDeleted, + ); + if (deadEntry) { + const reactivateObj = { + uSRMappingID: deadEntry.uSRMappingID, + deleted: false, + }; + allRequests.push( + group.serviceID === 4 + ? this.worklocationmapping.DeleteWorkLocationMappingForTM( + reactivateObj, + ) + : this.worklocationmapping.DeleteWorkLocationMapping(reactivateObj), + ); + // Reactivating only flips the deleted flag — refresh its location/ + // role fields to what the edit form currently shows, same as a kept role. + allRequests.push( + this.worklocationmapping.UpdateWorkLocationMapping( + buildRoleUpdateObj(rid, deadEntry.uSRMappingID), + ), + ); + } + } + + // CREATE new rows for roles that never existed for this user+scope. // Batched into a single SaveWorkLocationMapping call (one previleges // entry, ID array holding every added role) instead of one call per // role. Firing them separately let the backend treat each create as @@ -5486,11 +5538,11 @@ export class WorkLocationMappingComponent // roles added alongside others (reproduced with StopTB Counsellor/ // Nurse). Location fields (incl. StopTB's nikshayTUID/nikshayFacilityID) // are per-group, not per-role, so batching them here is unchanged. - if (rolesToAdd.length > 0) { + if (rolesToCreateFresh.length > 0) { const newObj: any = { previleges: [ { - ID: rolesToAdd.map((rid) => { + ID: rolesToCreateFresh.map((rid) => { const roleName = this.getRoleNameById(rid); return { roleID: rid, From cb2bf51985d78ed0e201f18aab248e09f03151db Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 11 Sep 2026 13:40:25 +0530 Subject: [PATCH 5/5] chore: bump version to 3.8.4 Co-Authored-By: Claude Sonnet 5 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c6b72b8..dbb8e47 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.iemr.admin-ui admin-ui - 3.8.2 + 3.8.4 Admin-UI Piramal - admin: Module ui