From b023883233d8269452392aef71f58a9bd578f983 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 11 Nov 2025 14:31:41 +0530 Subject: [PATCH 1/2] Added the APi to manage organization hierarchies --- .../Controllers/OrganizationController.cs | 13 +++- .../Service/OrganizationService.cs | 71 +++++++++++++++++++ .../ServiceInterfaces/IOrganizationService.cs | 2 +- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/Marco.Pms.Services/Controllers/OrganizationController.cs b/Marco.Pms.Services/Controllers/OrganizationController.cs index 18e9fbc..ec6f168 100644 --- a/Marco.Pms.Services/Controllers/OrganizationController.cs +++ b/Marco.Pms.Services/Controllers/OrganizationController.cs @@ -104,7 +104,18 @@ namespace Marco.Pms.Services.Controllers return StatusCode(response.StatusCode, response); } - + [HttpPost("hierarchy/manage/{employeeId}")] + public async Task ManageOrganizationHierarchy(Guid employeeId, [FromBody] List model) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _organizationService.ManageOrganizationHierarchyAsync(employeeId, model, loggedInEmployee, tenantId, loggedOrganizationId); + if (response.Success) + { + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Organization_Hierarchy", Response = response.Data }; + await _signalR.SendNotificationAsync(notification); + } + return StatusCode(response.StatusCode, response); + } #endregion diff --git a/Marco.Pms.Services/Service/OrganizationService.cs b/Marco.Pms.Services/Service/OrganizationService.cs index 1d08336..bd5044e 100644 --- a/Marco.Pms.Services/Service/OrganizationService.cs +++ b/Marco.Pms.Services/Service/OrganizationService.cs @@ -780,8 +780,79 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Internal error", "An internal exception has occurred", 500); } } + public async Task> ManageOrganizationHierarchyAsync(Guid employeeId, List model, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId) + { + var reportToIDs = model.Select(oh => oh.ReportToId).ToList(); + var existingOrganizationHieraechies = await _context.OrganizationHierarchies + .AsNoTracking() + .Where(oh => oh.EmployeeId == employeeId && reportToIDs.Contains(oh.ReportToId) && oh.IsActive && oh.TenantId == tenantId) + .ToListAsync(); + var newOrganizationHieraechies = new List(); + var removeOrganizationHieraechies = new List(); + var orgHierarchyLogs = new List(); + foreach (var hierarchy in model) + { + var existingOrganizationHieraechy = existingOrganizationHieraechies.FirstOrDefault(oh => oh.EmployeeId == employeeId && oh.ReportToId == hierarchy.ReportToId); + if (hierarchy.IsActive && existingOrganizationHieraechy == null) + { + var newOrganizationHieraechy = new OrganizationHierarchy + { + Id = Guid.NewGuid(), + EmployeeId = employeeId, + ReportToId = hierarchy.ReportToId, + IsPrimary = hierarchy.IsPrimary, + IsActive = true, + AssignedAt = DateTime.UtcNow, + AssignedById = loggedInEmployee.Id, + TenantId = tenantId + }; + newOrganizationHieraechies.Add(newOrganizationHieraechy); + } + else if (!hierarchy.IsActive && existingOrganizationHieraechy != null) + { + existingOrganizationHieraechy.IsActive = false; + removeOrganizationHieraechies.Add(existingOrganizationHieraechy); + + orgHierarchyLogs.Add(new OrgHierarchyLog + { + Id = Guid.NewGuid(), + OrganizationHierarchyId = existingOrganizationHieraechy.Id, + ReAssignedAt = DateTime.UtcNow, + ReAssignedById = loggedInEmployee.Id, + TenantId = tenantId + }); + } + } + + if (newOrganizationHieraechies.Any()) + { + _context.OrganizationHierarchies.AddRange(newOrganizationHieraechies); + } + if (removeOrganizationHieraechies.Any()) + { + _context.OrganizationHierarchies.UpdateRange(removeOrganizationHieraechies); + } + if (orgHierarchyLogs.Any()) + { + _context.OrgHierarchyLogs.AddRange(orgHierarchyLogs); + } + + await _context.SaveChangesAsync(); + + var organizationHieraechies = await _context.OrganizationHierarchies + .Include(oh => oh.Employee).ThenInclude(e => e!.JobRole) + .Include(oh => oh.ReportTo).ThenInclude(e => e!.JobRole) + .Include(oh => oh.AssignedBy).ThenInclude(e => e!.JobRole) + .AsNoTracking() + .Where(oh => oh.EmployeeId == employeeId && oh.IsActive && oh.TenantId == tenantId) + .OrderByDescending(oh => oh.AssignedAt) + .ToListAsync(); + + var response = _mapper.Map>(organizationHieraechies); + return ApiResponse.SuccessResponse(response, $"{response.Count} superior fetched successfully", 200); + } #endregion #region =================================================================== Put Functions =================================================================== diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IOrganizationService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IOrganizationService.cs index b1087e4..bc9c612 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IOrganizationService.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IOrganizationService.cs @@ -16,7 +16,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> CreateOrganizationAsync(CreateOrganizationDto model, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId); Task> AssignOrganizationToProjectAsync(AssignOrganizationDto model, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId); Task> AssignOrganizationToTenantAsync(Guid organizationId, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId); - + Task> ManageOrganizationHierarchyAsync(Guid employeeId, List model, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId); #endregion #region =================================================================== Put Functions =================================================================== From 128417858e2d7e8417590d15e0a990daefea0b0a Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 11 Nov 2025 16:53:51 +0530 Subject: [PATCH 2/2] Optimized the manage organization hierarchy API --- .../Service/OrganizationService.cs | 209 +++++++++++++----- 1 file changed, 149 insertions(+), 60 deletions(-) diff --git a/Marco.Pms.Services/Service/OrganizationService.cs b/Marco.Pms.Services/Service/OrganizationService.cs index bd5044e..1c92cea 100644 --- a/Marco.Pms.Services/Service/OrganizationService.cs +++ b/Marco.Pms.Services/Service/OrganizationService.cs @@ -780,79 +780,168 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Internal error", "An internal exception has occurred", 500); } } + + /// + /// Atomically manage the organization hierarchy for an employee: add, deactivate, and audit changes. + /// + /// Employee GUID to manage hierarchies for. + /// List of hierarchy changes (DTOs). + /// Current user performing the operation. + /// Tenant context for multi-tenancy support. + /// Current logged-in organization context. + /// Standardized ApiResponse with updated hierarchy or error details. public async Task> ManageOrganizationHierarchyAsync(Guid employeeId, List model, Employee loggedInEmployee, Guid tenantId, Guid loggedOrganizationId) { - var reportToIDs = model.Select(oh => oh.ReportToId).ToList(); - var existingOrganizationHieraechies = await _context.OrganizationHierarchies - .AsNoTracking() - .Where(oh => oh.EmployeeId == employeeId && reportToIDs.Contains(oh.ReportToId) && oh.IsActive && oh.TenantId == tenantId) - .ToListAsync(); - var newOrganizationHieraechies = new List(); - var removeOrganizationHieraechies = new List(); - var orgHierarchyLogs = new List(); - - foreach (var hierarchy in model) + // Validate required parameters early to avoid wasted DB calls + if (tenantId == Guid.Empty || loggedOrganizationId == Guid.Empty) { - var existingOrganizationHieraechy = existingOrganizationHieraechies.FirstOrDefault(oh => oh.EmployeeId == employeeId && oh.ReportToId == hierarchy.ReportToId); - if (hierarchy.IsActive && existingOrganizationHieraechy == null) + _logger.LogWarning("Unauthorized attempt: Invalid tenant or organization IDs. TenantId: {TenantId}, OrgId: {OrgId}", tenantId, loggedOrganizationId); + + return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant or organization context.", 403); + } + + if (model == null || model.Count == 0) + { + _logger.LogInfo("No data provided for employee {EmployeeId} hierarchy update.", employeeId); + return ApiResponse.ErrorResponse("No hierarchy data provided.", "No hierarchy data provided.", 400); + } + var primaryHierarchies = model.Where(oh => oh.IsPrimary && oh.IsActive).ToList(); + // Check if multiple primary hierarchies are provided for the employee + if (primaryHierarchies.Count > 1) + { + // Log a warning indicating multiple primary hierarchies are not allowed + _logger.LogWarning("Multiple primary hierarchy entries detected for employee {EmployeeId}. Only one primary hierarchy is allowed.", employeeId); + + // Return a bad request response with a clear, user-friendly message and an error code + return ApiResponse.ErrorResponse( + "Multiple primary hierarchies detected. Only one primary hierarchy is permitted per employee.", + "Multiple primary hierarchies detected. Only one primary hierarchy is permitted per employee.", + 400); + } + + + try + { + // Fetch current active hierarchies for employee and tenant status, no tracking needed since we will update selectively + var existingHierarchies = await _context.OrganizationHierarchies + .Where(oh => oh.EmployeeId == employeeId && oh.IsActive && oh.TenantId == tenantId) + .ToListAsync(); + + var newEntries = new List(); + var deactivateEntries = new List(); + var auditLogs = new List(); + + // Cache primary hierarchy for quick reference to enforce business rules about one primary per employee + var existingPrimary = existingHierarchies.FirstOrDefault(oh => oh.IsPrimary); + + // Process each input model item intelligently + foreach (var dto in model) { - var newOrganizationHieraechy = new OrganizationHierarchy + var matchingEntry = existingHierarchies + .FirstOrDefault(oh => oh.ReportToId == dto.ReportToId && oh.IsPrimary == dto.IsPrimary); + + if (dto.IsActive) { - Id = Guid.NewGuid(), - EmployeeId = employeeId, - ReportToId = hierarchy.ReportToId, - IsPrimary = hierarchy.IsPrimary, - IsActive = true, - AssignedAt = DateTime.UtcNow, - AssignedById = loggedInEmployee.Id, - TenantId = tenantId - }; - newOrganizationHieraechies.Add(newOrganizationHieraechy); + // Add new entry if none exists + if (matchingEntry == null) + { + // Enforce primary uniqueness by checking if a primary exists and whether client intends to deactivate the old one + if (dto.IsPrimary && existingPrimary != null) + { + var intendedPrimaryDeactivation = model.Any(m => + m.IsPrimary && !m.IsActive && m.ReportToId == existingPrimary.ReportToId); - } - else if (!hierarchy.IsActive && existingOrganizationHieraechy != null) - { - existingOrganizationHieraechy.IsActive = false; - removeOrganizationHieraechies.Add(existingOrganizationHieraechy); + if (!intendedPrimaryDeactivation) + { + _logger.LogWarning("Attempt to assign a second primary hierarchy for employee {EmployeeId} without deactivating current one.", + employeeId); + continue; // Skip this to maintain data integrity + } + } - orgHierarchyLogs.Add(new OrgHierarchyLog + newEntries.Add(new OrganizationHierarchy + { + Id = Guid.NewGuid(), + EmployeeId = employeeId, + ReportToId = dto.ReportToId, + IsPrimary = dto.IsPrimary, + IsActive = true, + AssignedAt = DateTime.UtcNow, + AssignedById = loggedInEmployee.Id, + TenantId = tenantId + }); + + _logger.LogInfo("Prepared new active hierarchy link: EmployeeId {EmployeeId}, ReportsTo {ReportToId}, Primary {Primary}", + employeeId, dto.ReportToId, dto.IsPrimary); + } + } + else { - Id = Guid.NewGuid(), - OrganizationHierarchyId = existingOrganizationHieraechy.Id, - ReAssignedAt = DateTime.UtcNow, - ReAssignedById = loggedInEmployee.Id, - TenantId = tenantId - }); + // Deactivate existing entry if found and allowed + if (matchingEntry != null) + { + if (dto.IsPrimary) + { + // Confirm alternative primary exists on active state to avoid orphan primary state + var alternativePrimaryExists = model.Any(m => + m.IsPrimary && m.IsActive && m.ReportToId != dto.ReportToId); + + if (!alternativePrimaryExists) + { + _logger.LogWarning("Attempt to deactivate sole primary hierarchy for employee {EmployeeId} prevented.", employeeId); + continue; // Skip deactivation to avoid orphan primary + } + } + + matchingEntry.IsActive = false; + deactivateEntries.Add(matchingEntry); + + auditLogs.Add(new OrgHierarchyLog + { + Id = Guid.NewGuid(), + OrganizationHierarchyId = matchingEntry.Id, + ReAssignedAt = DateTime.UtcNow, + ReAssignedById = loggedInEmployee.Id, + TenantId = tenantId + }); + + _logger.LogInfo("Marked hierarchy for deactivation: EmployeeId {EmployeeId}, ReportsTo {ReportToId}", employeeId, dto.ReportToId); + } + } } - } - if (newOrganizationHieraechies.Any()) + // Batch database operations for insertions and updates + if (newEntries.Any()) _context.OrganizationHierarchies.AddRange(newEntries); + if (deactivateEntries.Any()) _context.OrganizationHierarchies.UpdateRange(deactivateEntries); + if (auditLogs.Any()) _context.OrgHierarchyLogs.AddRange(auditLogs); + + await _context.SaveChangesAsync(); + + // Reload updated active hierarchy with related entities to respond with fresh data + var updatedHierarchy = await _context.OrganizationHierarchies + .Include(o => o.Employee).ThenInclude(e => e!.JobRole) + .Include(o => o.ReportTo).ThenInclude(e => e!.JobRole) + .Include(o => o.AssignedBy).ThenInclude(e => e!.JobRole) + .AsNoTracking() + .Where(oh => oh.EmployeeId == employeeId && oh.IsActive && oh.TenantId == tenantId) + .OrderByDescending(oh => oh.AssignedAt) + .ToListAsync(); + + var response = _mapper.Map>(updatedHierarchy); + + _logger.LogInfo("Organization hierarchy update completed for employee {EmployeeId}. NewEntries: {NewCount}, DeactivatedEntries: {DeactivatedCount}, LogsCreated: {LogCount}.", + employeeId, newEntries.Count, deactivateEntries.Count, auditLogs.Count); + + return ApiResponse.SuccessResponse(response, $"{response.Count} active superior(s) retrieved and updated successfully.", 200); + } + catch (Exception ex) { - _context.OrganizationHierarchies.AddRange(newOrganizationHieraechies); + _logger.LogError(ex, "Exception while managing organization hierarchy for employee {EmployeeId} in tenant {TenantId}.", employeeId, tenantId); + return ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred while processing the request.", 500); } - if (removeOrganizationHieraechies.Any()) - { - _context.OrganizationHierarchies.UpdateRange(removeOrganizationHieraechies); - } - if (orgHierarchyLogs.Any()) - { - _context.OrgHierarchyLogs.AddRange(orgHierarchyLogs); - } - - await _context.SaveChangesAsync(); - - var organizationHieraechies = await _context.OrganizationHierarchies - .Include(oh => oh.Employee).ThenInclude(e => e!.JobRole) - .Include(oh => oh.ReportTo).ThenInclude(e => e!.JobRole) - .Include(oh => oh.AssignedBy).ThenInclude(e => e!.JobRole) - .AsNoTracking() - .Where(oh => oh.EmployeeId == employeeId && oh.IsActive && oh.TenantId == tenantId) - .OrderByDescending(oh => oh.AssignedAt) - .ToListAsync(); - - var response = _mapper.Map>(organizationHieraechies); - return ApiResponse.SuccessResponse(response, $"{response.Count} superior fetched successfully", 200); } + + #endregion #region =================================================================== Put Functions ===================================================================