From c820b973a852753e3c577d505d5a2a197233c61d Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 9 Dec 2025 16:31:11 +0530 Subject: [PATCH 1/6] Optimize the get list of basic project for both infra and service --- .../Controllers/AttendanceController.cs | 6 +- .../Controllers/DashboardController.cs | 6 +- .../Controllers/DocumentController.cs | 4 +- .../Controllers/EmployeeController.cs | 6 +- .../Controllers/ImageController.cs | 2 +- Marco.Pms.Services/Service/ExpensesService.cs | 163 ++++++++------- .../Service/PermissionServices.cs | 14 +- Marco.Pms.Services/Service/ProjectServices.cs | 187 +++++++++++------- .../ServiceInterfaces/IProjectServices.cs | 2 +- 9 files changed, 219 insertions(+), 171 deletions(-) diff --git a/Marco.Pms.Services/Controllers/AttendanceController.cs b/Marco.Pms.Services/Controllers/AttendanceController.cs index 403dccf..0e9bb66 100644 --- a/Marco.Pms.Services/Controllers/AttendanceController.cs +++ b/Marco.Pms.Services/Controllers/AttendanceController.cs @@ -174,7 +174,7 @@ namespace MarcoBMS.Services.Controllers var hasTeamAttendancePermission = await _permission.HasPermission(PermissionsMaster.TeamAttendance, loggedInEmployee.Id); var hasSelfAttendancePermission = await _permission.HasPermission(PermissionsMaster.SelfAttendance, loggedInEmployee.Id); - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); + var hasProjectPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId); if (!hasProjectPermission) { @@ -353,7 +353,7 @@ namespace MarcoBMS.Services.Controllers return NotFound(ApiResponse.ErrorResponse("Project not found.")); } - if (!await _permission.HasProjectPermission(loggedInEmployee, projectId)) + if (!await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId)) { _logger.LogWarning("Unauthorized access attempt by EmployeeId: {EmployeeId} for ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); return Unauthorized(ApiResponse.ErrorResponse("You do not have permission to access this project.")); @@ -399,7 +399,7 @@ namespace MarcoBMS.Services.Controllers Employee LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var result = new List(); - var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId); + var hasProjectPermission = await _permission.HasInfraProjectPermission(LoggedInEmployee.Id, projectId); if (!hasProjectPermission) { diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index d7294c6..905059d 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -269,7 +269,7 @@ namespace Marco.Pms.Services.Controllers using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); // Security Check: Ensure the requested project is in the user's accessible list. - var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); + var hasPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId} (not active or not accessible).", loggedInEmployee.Id, projectId.Value); @@ -358,7 +358,7 @@ namespace Marco.Pms.Services.Controllers var _permission = scope.ServiceProvider.GetRequiredService(); // 2a. Security Check: Verify permission for the specific project. - var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); + var hasPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId.Value); @@ -689,7 +689,7 @@ namespace Marco.Pms.Services.Controllers using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); - bool hasPermission = await _permission.HasProjectPermission(loggedInEmployee!, projectId); + bool hasPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId); if (!hasPermission) { _logger.LogWarning("Unauthorized access by EmployeeId: {EmployeeId} to ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); diff --git a/Marco.Pms.Services/Controllers/DocumentController.cs b/Marco.Pms.Services/Controllers/DocumentController.cs index 4fdb588..7ecbc22 100644 --- a/Marco.Pms.Services/Controllers/DocumentController.cs +++ b/Marco.Pms.Services/Controllers/DocumentController.cs @@ -96,7 +96,7 @@ namespace Marco.Pms.Services.Controllers // Project permission check if (ProjectEntity == entityTypeId) { - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, entityId); + var hasProjectPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, entityId); if (!hasProjectPermission) { _logger.LogWarning("Employee {EmployeeId} does not have project access for ProjectId {ProjectId}", loggedInEmployee.Id, entityId); @@ -1085,7 +1085,7 @@ namespace Marco.Pms.Services.Controllers entityExists = await _context.Projects.AnyAsync(p => p.Id == oldAttachment.EntityId && p.TenantId == tenantId); if (entityExists) { - entityExists = await _permission.HasProjectPermission(loggedInEmployee, oldAttachment.EntityId); + entityExists = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, oldAttachment.EntityId); } } else diff --git a/Marco.Pms.Services/Controllers/EmployeeController.cs b/Marco.Pms.Services/Controllers/EmployeeController.cs index 0555529..2496fe7 100644 --- a/Marco.Pms.Services/Controllers/EmployeeController.cs +++ b/Marco.Pms.Services/Controllers/EmployeeController.cs @@ -153,7 +153,7 @@ namespace MarcoBMS.Services.Controllers return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); } // Check if the logged-in employee has permission for the requested project - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); + var hasProjectPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId); if (!hasProjectPermission) { _logger.LogWarning("User {EmployeeId} attempts to get employees for project {ProjectId} without permission", loggedInEmployee.Id, projectId); @@ -333,7 +333,7 @@ namespace MarcoBMS.Services.Controllers var employeeQuery = _context.Employees.Where(e => e.IsActive); if (projectId != null && projectId != Guid.Empty) { - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); + var hasProjectPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId.Value); if (!hasProjectPermission) { _logger.LogWarning("User {EmployeeId} attempts to get employee for project {ProjectId}, but not have access to the project", loggedInEmployee.Id, projectId); @@ -401,7 +401,7 @@ namespace MarcoBMS.Services.Controllers loggedInEmployee.Id, projectId); // Validate project access permission - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); + var hasProjectPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId.Value); if (!hasProjectPermission) { _logger.LogWarning("Access denied. EmployeeId: {EmployeeId} does not have permission for ProjectId: {ProjectId}", diff --git a/Marco.Pms.Services/Controllers/ImageController.cs b/Marco.Pms.Services/Controllers/ImageController.cs index 0cb1c95..ec6f5f8 100644 --- a/Marco.Pms.Services/Controllers/ImageController.cs +++ b/Marco.Pms.Services/Controllers/ImageController.cs @@ -63,7 +63,7 @@ namespace Marco.Pms.Services.Controllers } // Step 2: Check project access permission - var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); + var hasPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId); if (!hasPermission) { _logger.LogWarning("[GetImageList] Access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); diff --git a/Marco.Pms.Services/Service/ExpensesService.cs b/Marco.Pms.Services/Service/ExpensesService.cs index 6b72397..3f988f8 100644 --- a/Marco.Pms.Services/Service/ExpensesService.cs +++ b/Marco.Pms.Services/Service/ExpensesService.cs @@ -594,90 +594,6 @@ namespace Marco.Pms.Services.Service } } - #region Helper Methods (Private) - - /// - /// Fetches the list of possible next statuses based on current status. - /// - private async Task> GetNextStatusesAsync(Guid? currentStatusId, Guid tenantId) - { - if (!currentStatusId.HasValue) return new List(); - - return await _context.ExpensesStatusMapping - .AsNoTracking() - .Include(esm => esm.NextStatus).ThenInclude(s => s!.StatusPermissionMappings) - .Where(esm => esm.StatusId == currentStatusId && esm.Status != null) - .Select(esm => esm.NextStatus!) // Select only the NextStatus entity - .ToListAsync(); - } - - /// - /// Filters statuses by permission and reorders specific actions (e.g., Reject). - /// - private List ProcessNextStatuses(List nextStatuses, Guid createdById, Guid loggedInEmployeeId, List userPermissionIds) - { - if (nextStatuses == null || !nextStatuses.Any()) return new List(); - - // Business Logic: Move "Reject" to the top - var rejectStatus = nextStatuses.FirstOrDefault(ns => ns.DisplayName == "Reject"); - if (rejectStatus != null) - { - nextStatuses.Remove(rejectStatus); - nextStatuses.Insert(0, rejectStatus); - } - - var resultVMs = new List(); - - foreach (var item in nextStatuses) - { - var vm = _mapper.Map(item); - - // Case 1: If Creator is viewing and status is Review (Assuming Review is a constant GUID or Enum) - if (item.Id == Review && createdById == loggedInEmployeeId) - { - resultVMs.Add(vm); - continue; - } - - // Case 2: Standard Permission Check - bool hasPermission = vm.PermissionIds.Any(pid => userPermissionIds.Contains(pid)); - - // Exclude "Done" status (Assuming Done is a constant GUID) - if (hasPermission && item.Id != Done) - { - resultVMs.Add(vm); - } - } - - return resultVMs.Distinct().ToList(); - } - - /// - /// Attempts to fetch project details from Projects table, falling back to ServiceProjects. - /// - private async Task GetProjectDetailsAsync(Guid? projectId, Guid tenantId) - { - if (!projectId.HasValue) return null; - - // Check Infrastructure Projects - var infraProject = await _context.Projects - .AsNoTracking() - .Where(p => p.Id == projectId && p.TenantId == tenantId) - .ProjectTo(_mapper.ConfigurationProvider) // Optimized: Project directly to VM inside SQL - .FirstOrDefaultAsync(); - - if (infraProject != null) return infraProject; - - // Fallback to Service Projects - return await _context.ServiceProjects - .AsNoTracking() - .Where(sp => sp.Id == projectId && sp.TenantId == tenantId) - .ProjectTo(_mapper.ConfigurationProvider) - .FirstOrDefaultAsync(); - } - - #endregion - public async Task> GetSupplerNameListAsync(Employee loggedInEmployee, Guid tenantId) { try @@ -4367,6 +4283,85 @@ namespace Marco.Pms.Services.Service return CreateExpenseAttachmentEntities(batchId, expense.Id, employeeId, tenantId, objectKey, attachment); } + /// + /// Fetches the list of possible next statuses based on current status. + /// + private async Task> GetNextStatusesAsync(Guid? currentStatusId, Guid tenantId) + { + if (!currentStatusId.HasValue) return new List(); + + return await _context.ExpensesStatusMapping + .AsNoTracking() + .Include(esm => esm.NextStatus).ThenInclude(s => s!.StatusPermissionMappings) + .Where(esm => esm.StatusId == currentStatusId && esm.Status != null) + .Select(esm => esm.NextStatus!) // Select only the NextStatus entity + .ToListAsync(); + } + + /// + /// Filters statuses by permission and reorders specific actions (e.g., Reject). + /// + private List ProcessNextStatuses(List nextStatuses, Guid createdById, Guid loggedInEmployeeId, List userPermissionIds) + { + if (nextStatuses == null || !nextStatuses.Any()) return new List(); + + // Business Logic: Move "Reject" to the top + var rejectStatus = nextStatuses.FirstOrDefault(ns => ns.DisplayName == "Reject"); + if (rejectStatus != null) + { + nextStatuses.Remove(rejectStatus); + nextStatuses.Insert(0, rejectStatus); + } + + var resultVMs = new List(); + + foreach (var item in nextStatuses) + { + var vm = _mapper.Map(item); + + // Case 1: If Creator is viewing and status is Review (Assuming Review is a constant GUID or Enum) + if (item.Id == Review && createdById == loggedInEmployeeId) + { + resultVMs.Add(vm); + continue; + } + + // Case 2: Standard Permission Check + bool hasPermission = vm.PermissionIds.Any(pid => userPermissionIds.Contains(pid)); + + // Exclude "Done" status (Assuming Done is a constant GUID) + if (hasPermission && item.Id != Done) + { + resultVMs.Add(vm); + } + } + + return resultVMs.Distinct().ToList(); + } + + /// + /// Attempts to fetch project details from Projects table, falling back to ServiceProjects. + /// + private async Task GetProjectDetailsAsync(Guid? projectId, Guid tenantId) + { + if (!projectId.HasValue) return null; + + // Check Infrastructure Projects + var infraProject = await _context.Projects + .AsNoTracking() + .Where(p => p.Id == projectId && p.TenantId == tenantId) + .ProjectTo(_mapper.ConfigurationProvider) // Optimized: Project directly to VM inside SQL + .FirstOrDefaultAsync(); + + if (infraProject != null) return infraProject; + + // Fallback to Service Projects + return await _context.ServiceProjects + .AsNoTracking() + .Where(sp => sp.Id == projectId && sp.TenantId == tenantId) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + } /// /// A private static helper method to create Document and BillAttachment entities. diff --git a/Marco.Pms.Services/Service/PermissionServices.cs b/Marco.Pms.Services/Service/PermissionServices.cs index cbe5dd2..ca58119 100644 --- a/Marco.Pms.Services/Service/PermissionServices.cs +++ b/Marco.Pms.Services/Service/PermissionServices.cs @@ -1,5 +1,4 @@ using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; using Marco.Pms.Services.Helpers; using MarcoBMS.Services.Helpers; @@ -50,29 +49,28 @@ namespace Marco.Pms.Services.Service var hasPermission = toCheckPermissionIds.Any(f => realPermissionIds.Contains(f)); return hasPermission; } - public async Task HasProjectPermission(Employee LoggedInEmployee, Guid projectId) + public async Task HasInfraProjectPermission(Guid loggedInEmployeeId, Guid projectId) { - var employeeId = LoggedInEmployee.Id; - var projectIds = await _cache.GetProjects(employeeId, tenantId); + var projectIds = await _cache.GetProjects(loggedInEmployeeId, tenantId); if (projectIds == null) { - var hasPermission = await HasPermission(PermissionsMaster.ManageProject, employeeId); + var hasPermission = await HasPermission(PermissionsMaster.ManageProject, loggedInEmployeeId); if (hasPermission) { - var projects = await _context.Projects.AsNoTracking().Where(c => c.TenantId == LoggedInEmployee.TenantId).ToListAsync(); + var projects = await _context.Projects.AsNoTracking().Where(c => c.TenantId == tenantId).ToListAsync(); projectIds = projects.Select(p => p.Id).ToList(); } else { - var allocation = await _context.ProjectAllocations.AsNoTracking().Where(c => c.EmployeeId == employeeId && c.IsActive).ToListAsync(); + var allocation = await _context.ProjectAllocations.AsNoTracking().Where(c => c.EmployeeId == loggedInEmployeeId && c.IsActive).ToListAsync(); if (!allocation.Any()) { return false; } projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); } - await _cache.AddProjects(LoggedInEmployee.Id, projectIds, tenantId); + await _cache.AddProjects(loggedInEmployeeId, projectIds, tenantId); } return projectIds.Contains(projectId); } diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index e9cb9d7..c2bd405 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -54,89 +54,144 @@ namespace Marco.Pms.Services.Service #region =================================================================== Project Get APIs ===================================================================\ - /// - /// Retrieves a combined list of basic infrastructure and active service projects accessible by the logged-in employee within a tenant. - /// - /// Optional search term to filter projects by name (if implemented). - /// Authenticated employee requesting the data. - /// Tenant identifier to ensure multi-tenant data isolation. - /// Returns an ApiResponse containing the distinct combined list of basic project view models or an error response. - public async Task> GetBothProjectBasicListAsync(Guid? id, string? searchString, Employee loggedInEmployee, Guid tenantId) + public async Task>> GetBothProjectBasicListAsync(Guid? id, string? searchString, Employee loggedInEmployee, Guid tenantId) { + // 1. Validation and Context Checks if (tenantId == Guid.Empty) { - _logger.LogWarning("GetBothProjectBasicListAsync called with invalid tenant context by EmployeeId {EmployeeId}", loggedInEmployee.Id); - return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); + _logger.LogWarning("Security Alert: GetBothProjectBasicListAsync called with empty TenantId by Employee: {EmployeeId}", loggedInEmployee.Id); + return ApiResponse>.ErrorResponse("Access Denied", "Invalid tenant context provided.", 403); } try { - // Retrieve list of project IDs accessible by the employee for tenant isolation and security - var accessibleProjectIds = await GetMyProjects(loggedInEmployee, tenantId); + _logger.LogInfo("Initiating project fetch for Tenant: {TenantId}, User: {UserId}. Search: {Search}", tenantId, loggedInEmployee.Id, searchString ?? "None"); - // Fetch infrastructure projects concurrently filtered by accessible IDs and tenant - var infraProjectTask = Task.Run(async () => + // 2. Check Permissions (Global check, do not block parallel tasks if possible, but needed for logic) + // usage of scoped service for permission check + using var scope = _serviceScopeFactory.CreateScope(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + bool hasManagePermission = await permissionService.HasPermission(PermissionsMaster.ManageProject, loggedInEmployee.Id); + + // 3. Define Parallel Tasks + // We use DbContextFactory to create short-lived contexts for safe parallel execution. + + // --- TASK A: Infrastructure Projects --- + var infraTask = Task.Run(async () => { - await using var context = await _dbContextFactory.CreateDbContextAsync(); - var infraProjectsQuery = context.Projects - .Where(p => accessibleProjectIds.Contains(p.Id) && p.TenantId == tenantId); + using var context = await _dbContextFactory.CreateDbContextAsync(); + + // Base Query + var query = context.Projects.AsNoTracking() + .Where(p => p.TenantId == tenantId); + + // Apply Filters + if (id.HasValue) + { + query = query.Where(p => p.Id == id.Value); + } if (!string.IsNullOrWhiteSpace(searchString)) { - var normalized = searchString.Trim().ToLowerInvariant(); - infraProjectsQuery = infraProjectsQuery - .Where(p => p.Name.ToLower().Contains(normalized) || - (!string.IsNullOrWhiteSpace(p.ShortName) && p.ShortName.ToLower().Contains(normalized))); - } - if (id.HasValue) - { - infraProjectsQuery = infraProjectsQuery.Where(p => p.Id == id.Value); + // Normalize search term. NOTE: Check if your DB Collation is already Case Insensitive (CI). + // If DB is CI, you don't need ToLower(). Assuming standard needed: + string normalized = searchString.Trim(); + query = query.Where(p => p.Name.Contains(normalized) || + (p.ShortName != null && p.ShortName.Contains(normalized))); } - var infraProjects = await infraProjectsQuery.ToListAsync(); - return infraProjects.Select(p => _mapper.Map(p)).ToList(); + // Apply Security (Row Level Access) + if (!hasManagePermission) + { + // Optimization: Use Any() to create an EXISTS clause in SQL rather than fetching IDs to memory + query = query.Where(p => context.ProjectAllocations.Any(pa => + pa.ProjectId == p.Id && + pa.EmployeeId == loggedInEmployee.Id && + pa.IsActive && + pa.TenantId == tenantId)); + } + + // Projection: Select only what is needed before hitting DB + return await query + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); }); - // Fetch active service projects concurrently with tenant isolation - var serviceProjectTask = Task.Run(async () => + + // --- TASK B: Service Projects --- + var serviceTask = Task.Run(async () => { - await using var context = await _dbContextFactory.CreateDbContextAsync(); - var serviceProjectsQuery = context.ServiceProjects + using var context = await _dbContextFactory.CreateDbContextAsync(); + + var query = context.ServiceProjects.AsNoTracking() .Where(sp => sp.TenantId == tenantId && sp.IsActive); - if (!string.IsNullOrWhiteSpace(searchString)) - { - var normalized = searchString.Trim().ToLowerInvariant(); - serviceProjectsQuery = serviceProjectsQuery - .Where(sp => sp.Name.ToLower().Contains(normalized) || - (!string.IsNullOrWhiteSpace(sp.ShortName) && sp.ShortName.ToLower().Contains(normalized))); - } - if (id.HasValue) { - serviceProjectsQuery = serviceProjectsQuery.Where(sp => sp.Id == id.Value); + query = query.Where(sp => sp.Id == id.Value); } - var serviceProjects = await serviceProjectsQuery.ToListAsync(); - return serviceProjects.Select(sp => _mapper.Map(sp)).ToList(); + if (!string.IsNullOrWhiteSpace(searchString)) + { + string normalized = searchString.Trim(); + query = query.Where(sp => sp.Name.Contains(normalized) || + (sp.ShortName != null && sp.ShortName.Contains(normalized))); + } + + if (!hasManagePermission) + { + // Optimization: Complex security filter pushed to DB + // User has access if: Allocated directly OR Mapped via JobTicket + query = query.Where(sp => + // Condition 1: Direct Allocation + context.ServiceProjectAllocations.Any(spa => + spa.ProjectId == sp.Id && + spa.EmployeeId == loggedInEmployee.Id && + spa.TenantId == tenantId && + spa.IsActive) + || + // Condition 2: Job Ticket Mapping + context.JobEmployeeMappings.Any(jem => + jem.JobTicket != null && + jem.JobTicket.ProjectId == sp.Id && + jem.AssigneeId == loggedInEmployee.Id && + jem.TenantId == tenantId) + ); + } + + return await query + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); }); - // Wait for both concurrent tasks to complete - await Task.WhenAll(infraProjectTask, serviceProjectTask); + // 4. Await Completion + await Task.WhenAll(infraTask, serviceTask); - // Combine, remove duplicates, and prepare response list - var combinedProjects = infraProjectTask.Result.Concat(serviceProjectTask.Result).OrderBy(p => p.Name).Distinct().ToList(); + // 5. Aggregate Results + // DistinctBy is available in .NET 6+. If older, use GroupBy or distinct comparer. + var infraResults = await infraTask; + var serviceResults = await serviceTask; - _logger.LogInfo("GetBothProjectBasicListAsync returning {Count} projects for tenant {TenantId} by EmployeeId {EmployeeId}", - combinedProjects.Count, tenantId, loggedInEmployee.Id); + var combinedProjects = infraResults + .Concat(serviceResults) + .DistinctBy(p => p.Id) // Ensure no duplicate IDs if cross-contamination exists + .OrderBy(p => p.Name) + .ToList(); - return ApiResponse.SuccessResponse(combinedProjects, "Service and infrastructure projects fetched successfully.", 200); + _logger.LogInfo("Successfully fetched {Count} projects ({InfraCount} Infra, {ServiceCount} Service) for User {UserId}.", + combinedProjects.Count, infraResults.Count, serviceResults.Count, loggedInEmployee.Id); + + return ApiResponse>.SuccessResponse(combinedProjects, "Projects retrieved successfully.", 200); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Project fetch operation was canceled by the client."); + return ApiResponse>.ErrorResponse("Request Canceled", "The operation was canceled.", 499); } catch (Exception ex) { - _logger.LogError(ex, "Unexpected error in GetBothProjectBasicListAsync for tenant {TenantId} by EmployeeId {EmployeeId}", - tenantId, loggedInEmployee.Id); - return ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while fetching projects.", 500); + _logger.LogError(ex, "CRITICAL: Failed to fetch projects for Tenant {TenantId}. Error: {Message}", tenantId, ex.Message); + return ApiResponse>.ErrorResponse("Internal Server Error", "An unexpected error occurred while processing your request.", 500); } } public async Task> GetAllProjectsBasicAsync(bool provideAll, Employee loggedInEmployee, Guid tenantId) @@ -278,7 +333,7 @@ namespace Marco.Pms.Services.Service var _permission = scope.ServiceProvider.GetRequiredService(); // --- Step 1: Run independent operations in PARALLEL --- // We can check permissions and fetch data at the same time to reduce latency. - var permissionTask = _permission.HasProjectPermission(loggedInEmployee, id); + var permissionTask = _permission.HasInfraProjectPermission(loggedInEmployee.Id, id); // This helper method encapsulates the "cache-first, then database" logic. var projectDataTask = GetProjectDataAsync(id, tenantId); @@ -333,7 +388,7 @@ namespace Marco.Pms.Services.Service } // Step 2: Check permission for this specific project - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, id); + var hasProjectPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, id); if (!hasProjectPermission) { _logger.LogWarning("Project-specific access denied. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployee.Id, id); @@ -649,7 +704,7 @@ namespace Marco.Pms.Services.Service } // 1c. Security Check - var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id); + var hasPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, id); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} attempting to update project {ProjectId}.", loggedInEmployee.Id, id); @@ -743,7 +798,7 @@ namespace Marco.Pms.Services.Service // --- CRITICAL: Security Check --- // Before fetching data, you MUST verify the user has permission to see it. // This is a placeholder for your actual permission logic. - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); + var hasProjectPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId); var hasAllEmployeePermission = await _permission.HasPermission(PermissionsMaster.ViewAllEmployees, loggedInEmployee.Id); var hasviewTeamPermission = await _permission.HasPermission(PermissionsMaster.ViewTeamMembers, loggedInEmployee.Id, projectId); @@ -824,7 +879,7 @@ namespace Marco.Pms.Services.Service // --- Step 2: Security and Existence Checks --- // Before fetching data, you MUST verify the user has permission to see it. // This is a placeholder for your actual permission logic. - var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); + var hasPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId); @@ -1333,7 +1388,7 @@ namespace Marco.Pms.Services.Service } // Check if the logged-in employee has permission for the requested project - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); + var hasProjectPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId); if (!hasProjectPermission) { _logger.LogWarning("User {EmployeeId} attempts to get employees for project {ProjectId} without permission", loggedInEmployee.Id, projectId); @@ -1416,7 +1471,7 @@ namespace Marco.Pms.Services.Service } // Check permission to view project team - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); + var hasProjectPermission = await _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId); if (!hasProjectPermission) { _logger.LogWarning("Access denied: User {EmployeeId} tried to get team for Project {ProjectId}", loggedInEmployee.Id, projectId); @@ -1507,7 +1562,7 @@ namespace Marco.Pms.Services.Service { var _permission = scope.ServiceProvider.GetRequiredService(); // --- Step 1: Run independent permission checks in PARALLEL --- - var projectPermissionTask = _permission.HasProjectPermission(loggedInEmployee, projectId); + var projectPermissionTask = _permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId); var viewInfraPermissionTask = Task.Run(async () => { using var newScope = _serviceScopeFactory.CreateScope(); @@ -1658,7 +1713,7 @@ namespace Marco.Pms.Services.Service { using var taskScope = _serviceScopeFactory.CreateScope(); var permission = taskScope.ServiceProvider.GetRequiredService(); - return await permission.HasProjectPermission(loggedInEmployee, projectId); + return await permission.HasInfraProjectPermission(loggedInEmployee.Id, projectId); }); var hasGenericViewInfraPermissionTask = Task.Run(async () => { @@ -2511,7 +2566,7 @@ namespace Marco.Pms.Services.Service } // Verify logged-in employee has permission on the project - var hasPermission = await permissionService.HasProjectPermission(loggedInEmployee, projectId); + var hasPermission = await permissionService.HasInfraProjectPermission(loggedInEmployee.Id, projectId); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} attempting to access project {ProjectId}.", loggedInEmployee.Id, projectId); @@ -2601,7 +2656,7 @@ namespace Marco.Pms.Services.Service } // Validate permission for logged-in employee to assign services to project - var hasPermission = await permissionService.HasProjectPermission(loggedInEmployee, model.ProjectId); + var hasPermission = await permissionService.HasInfraProjectPermission(loggedInEmployee.Id, model.ProjectId); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} attempting to assign services to project {ProjectId}.", loggedInEmployee.Id, model.ProjectId); @@ -2703,7 +2758,7 @@ namespace Marco.Pms.Services.Service } // Verify permission to update project - var hasPermission = await permissionService.HasProjectPermission(loggedInEmployee, model.ProjectId); + var hasPermission = await permissionService.HasInfraProjectPermission(loggedInEmployee.Id, model.ProjectId); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} trying to deassign services from project {ProjectId}.", loggedInEmployee.Id, model.ProjectId); @@ -2801,7 +2856,7 @@ namespace Marco.Pms.Services.Service } // Check if the logged in employee has permission to access the project - var hasPermission = await permissionService.HasProjectPermission(loggedInEmployee, projectId); + var hasPermission = await permissionService.HasInfraProjectPermission(loggedInEmployee.Id, projectId); if (!hasPermission) { _logger.LogWarning("Access denied for user {UserId} on project {ProjectId}", loggedInEmployee.Id, projectId); @@ -2970,7 +3025,7 @@ namespace Marco.Pms.Services.Service } // Check if the logged in employee has permission to access the project - var hasPermission = await permissionService.HasProjectPermission(loggedInEmployee, projectId); + var hasPermission = await permissionService.HasInfraProjectPermission(loggedInEmployee.Id, projectId); if (!hasPermission) { _logger.LogWarning("Access denied for user {UserId} on project {ProjectId}", loggedInEmployee.Id, projectId); diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index 2042987..8520d66 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -10,7 +10,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces { public interface IProjectServices { - Task> GetBothProjectBasicListAsync(Guid? id, string? searchString, Employee loggedInEmployee, Guid tenantId); + Task>> GetBothProjectBasicListAsync(Guid? id, string? searchString, Employee loggedInEmployee, Guid tenantId); Task> GetAllProjectsBasicAsync(bool provideAll, Employee loggedInEmployee, Guid tenantId); Task> GetAllProjectsAsync(string? searchString, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId); Task> GetProjectAsync(Guid id, Employee loggedInEmployee, Guid tenantId); From 7459876a20d42e64a923d1a957a8f5f75d4f1cde Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 9 Dec 2025 17:17:59 +0530 Subject: [PATCH 2/6] Added an Helper Function to get list all project Ids --- Marco.Pms.Services/Service/ProjectServices.cs | 202 ++++++++++++++++++ .../ServiceInterfaces/IProjectServices.cs | 1 + 2 files changed, 203 insertions(+) diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index c2bd405..a9cca93 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -3630,6 +3630,208 @@ namespace Marco.Pms.Services.Service return featurePermissionIds; } + public async Task> GetBothProjectIds(Guid loggedInEmployeeId, Guid tenantId) + { + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Security Alert: GetBothProjectBasicListAsync called with empty TenantId by Employee: {EmployeeId}", loggedInEmployeeId); + return new List(); + } + + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + bool hasManagePermission = await permissionService.HasPermission(PermissionsMaster.ManageProject, loggedInEmployeeId); + + var infraTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + if (hasManagePermission) + { + return await context.Projects.AsNoTracking() + .Where(p => p.TenantId == tenantId) + .Select(p => p.Id) + .ToListAsync(); + } + else + { + return await context.ProjectAllocations.AsNoTracking() + .Where(pa => pa.TenantId == tenantId && pa.EmployeeId == loggedInEmployeeId && pa.IsActive) + .Select(pa => pa.ProjectId) + .ToListAsync(); + } + + + }); + + var serviceTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + if (hasManagePermission) + { + return await context.ServiceProjects.AsNoTracking() + .Where(sp => sp.TenantId == tenantId && sp.IsActive) + .Select(sp => sp.Id) + .ToListAsync(); + } + else + { + var serviceProjectIds = await context.ServiceProjectAllocations.AsNoTracking() + .Where(spa => spa.TenantId == tenantId && spa.EmployeeId == loggedInEmployeeId && spa.IsActive) + .Select(spa => spa.ProjectId) + .ToListAsync(); + + var jobServiceProjectIds = await context.JobEmployeeMappings.AsNoTracking() + .Include(jem => jem.JobTicket) + .Where(jem => jem.TenantId == tenantId && jem.JobTicket != null && jem.JobTicket.IsActive && !jem.JobTicket.IsArchive && jem.AssigneeId == loggedInEmployeeId) + .Select(jem => jem.JobTicket!.ProjectId) + .ToListAsync(); + serviceProjectIds.Concat(jobServiceProjectIds).Distinct().ToList(); + return serviceProjectIds; + } + }); + + + await Task.WhenAll(infraTask, serviceTask); + + + var infraResults = await infraTask; + var serviceResults = await serviceTask; + + var combinedProjects = infraResults + .Concat(serviceResults) + .Distinct() + .ToList(); + + _logger.LogInfo("Successfully fetched {Count} projects ({InfraCount} Infra, {ServiceCount} Service) for User {UserId}.", + combinedProjects.Count, infraResults.Count, serviceResults.Count, loggedInEmployeeId); + + return combinedProjects; + } + catch (OperationCanceledException) + { + _logger.LogWarning("Project fetch operation was canceled by the client."); + return new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "CRITICAL: Failed to fetch projects for Tenant {TenantId}. Error: {Message}", tenantId, ex.Message); + return new List(); + } + } + + public async Task> GetBothProjectIdsAsync(Guid loggedInEmployeeId, Guid tenantId) + { + // 1. Guard Clause: Fast exit for invalid inputs + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Security Alert: GetBothProjectIdsAsync invoked with empty TenantId. User: {EmployeeId}", loggedInEmployeeId); + return new List(); + } + + try + { + _logger.LogInfo("Starting parallel fetch of Project IDs for Tenant: {TenantId}, User: {UserId}", tenantId, loggedInEmployeeId); + + // 2. Permission Check (Scoped) + using var scope = _serviceScopeFactory.CreateScope(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + bool hasManagePermission = await permissionService.HasPermission(PermissionsMaster.ManageProject, loggedInEmployeeId); + + // 3. Parallel Execution: Infrastructure Projects + var infraTask = Task.Run(async () => + { + // Create isolated context for thread safety + using var context = await _dbContextFactory.CreateDbContextAsync(); + + if (hasManagePermission) + { + // Admin: Fetch all IDs for tenant + return await context.Projects.AsNoTracking() + .Where(p => p.TenantId == tenantId) + .Select(p => p.Id) + .ToListAsync(); + } + else + { + // User: Fetch only allocated IDs + return await context.ProjectAllocations.AsNoTracking() + .Where(pa => pa.TenantId == tenantId && pa.EmployeeId == loggedInEmployeeId && pa.IsActive) + .Select(pa => pa.ProjectId) + .ToListAsync(); + } + }); + + // 4. Parallel Execution: Service Projects + var serviceTask = Task.Run(async () => + { + using var context = await _dbContextFactory.CreateDbContextAsync(); + + if (hasManagePermission) + { + // Admin: Fetch all active Service Projects + return await context.ServiceProjects.AsNoTracking() + .Where(sp => sp.TenantId == tenantId && sp.IsActive) + .Select(sp => sp.Id) + .ToListAsync(); + } + else + { + // User: Complex Logic (Direct Allocation OR Job Ticket Mapping) + + // Query A: Direct Allocations + var directAllocationsQuery = context.ServiceProjectAllocations.AsNoTracking() + .Where(spa => spa.TenantId == tenantId && spa.EmployeeId == loggedInEmployeeId && spa.IsActive) + .Select(spa => spa.ProjectId); + + // Query B: Via Job Tickets + // Note: Removed .Include(); EF Core automatically joins when we access jem.JobTicket.ProjectId + var jobMappingsQuery = context.JobEmployeeMappings.AsNoTracking() + .Where(jem => jem.TenantId == tenantId + && jem.AssigneeId == loggedInEmployeeId + && jem.JobTicket != null + && jem.JobTicket.IsActive + && !jem.JobTicket.IsArchive) + .Select(jem => jem.JobTicket!.ProjectId); + + // OPTIMIZATION: Use LINQ Union to combine queries into ONE SQL statement. + // This performs the DISTINCT operation in the database, not in memory. + return await directAllocationsQuery + .Union(jobMappingsQuery) + .ToListAsync(); + } + }); + + // 5. Await both tasks + await Task.WhenAll(infraTask, serviceTask); + + // 6. Merge Results + // Distinct() ensures that if a Project ID exists in both Infra and Service (rare, but possible by ID collision or bad data), we don't duplicate. + var combinedIds = (await infraTask) + .Concat(await serviceTask) + .Distinct() + .ToList(); + + _logger.LogInfo("Completed Project ID fetch. Total: {Count} (Infra: {InfraCount}, Service: {ServiceCount}) for User {UserId}", + combinedIds.Count, infraTask.Result.Count, serviceTask.Result.Count, loggedInEmployeeId); + + return combinedIds; + } + catch (OperationCanceledException) + { + _logger.LogWarning("GetBothProjectIdsAsync operation canceled by client request."); + return new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "CRITICAL failure in GetBothProjectIdsAsync for Tenant {TenantId}. Message: {Message}", tenantId, ex.Message); + return new List(); + } + } + #endregion } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index 8520d66..244d95f 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -51,6 +51,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> GetAssignedOrganizationsToProjectAsync(Guid projectId, Employee loggedInEmployee, Guid tenantId); Task> GetAssignedOrganizationsToProjectForDropdownAsync(Guid projectId, Employee loggedInEmployee, Guid tenantId); + Task> GetBothProjectIdsAsync(Guid loggedInEmployeeId, Guid tenantId); } } From 4853613efd598ae150acba0bc189628373f8d2d9 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 9 Dec 2025 18:59:07 +0530 Subject: [PATCH 3/6] Checking the if employee is actively assigned to the project when getting list of emplyees when assigning task --- Marco.Pms.Services/Service/ProjectServices.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index a9cca93..48e2cc1 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -1434,8 +1434,7 @@ namespace Marco.Pms.Services.Service return ApiResponse.SuccessResponse(result, "Employee list fetched successfully", 200); } - public async Task> GetProjectTeamByServiceAndOrganizationAsync( - Guid projectId, Guid? serviceId, Guid? organizationId, Employee loggedInEmployee, Guid tenantId) + public async Task> GetProjectTeamByServiceAndOrganizationAsync(Guid projectId, Guid? serviceId, Guid? organizationId, Employee loggedInEmployee, Guid tenantId) { _logger.LogDebug("Started fetching project team. ProjectId: {ProjectId}, ServiceId: {ServiceId}, OrganizationId: {OrganizationId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", projectId, serviceId ?? Guid.Empty, organizationId ?? Guid.Empty, tenantId, loggedInEmployee.Id); @@ -1513,7 +1512,8 @@ namespace Marco.Pms.Services.Service .ThenInclude(e => e!.JobRole) .Where(pa => pa.ProjectId == projectId && pa.Employee != null - && organizationIds.Contains(pa.Employee.OrganizationId)); + && organizationIds.Contains(pa.Employee.OrganizationId) + && pa.IsActive); if (serviceId.HasValue) { @@ -1530,6 +1530,8 @@ namespace Marco.Pms.Services.Service var employeeList = projectAllocations .Select(pa => _mapper.Map(pa.Employee)) .Distinct() + .OrderBy(e => e.FirstName) + .ThenBy(e => e.LastName) .ToList(); _logger.LogInfo("Fetched {EmployeeCount} employees for Project {ProjectId}.", employeeList.Count, projectId); From 5387e009cbd373aad12fd2e1c068d3805406babe Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 9 Dec 2025 19:13:08 +0530 Subject: [PATCH 4/6] sorted the service , activity group and activity by names --- .../Controllers/MasterController.cs | 4 ++-- Marco.Pms.Services/Service/MasterService.cs | 17 ++++++++++++----- .../Service/ServiceInterfaces/IMasterService.cs | 4 +++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Marco.Pms.Services/Controllers/MasterController.cs b/Marco.Pms.Services/Controllers/MasterController.cs index 4a7b142..0169651 100644 --- a/Marco.Pms.Services/Controllers/MasterController.cs +++ b/Marco.Pms.Services/Controllers/MasterController.cs @@ -257,10 +257,10 @@ namespace Marco.Pms.Services.Controllers [HttpGet] [Route("activities")] - public async Task GetActivitiesMaster([FromQuery] Guid? activityGroupId) + public async Task GetActivitiesMaster([FromQuery] Guid? activityGroupId, [FromQuery] string? searchString) { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _masterService.GetActivitiesMasterAsync(activityGroupId, loggedInEmployee, tenantId); + var response = await _masterService.GetActivitiesMasterAsync(activityGroupId, searchString, loggedInEmployee, tenantId); return StatusCode(response.StatusCode, response); } diff --git a/Marco.Pms.Services/Service/MasterService.cs b/Marco.Pms.Services/Service/MasterService.cs index baac924..763c282 100644 --- a/Marco.Pms.Services/Service/MasterService.cs +++ b/Marco.Pms.Services/Service/MasterService.cs @@ -521,6 +521,7 @@ namespace Marco.Pms.Services.Service var services = await _context.ServiceMasters .Where(s => s.TenantId == tenantId && s.IsActive) .Select(s => _mapper.Map(s)) + .OrderBy(s => s.Name) .ToListAsync(); _logger.LogInfo("Fetched {Count} service records for tenantId: {TenantId}", services.Count, tenantId); @@ -628,11 +629,11 @@ namespace Marco.Pms.Services.Service IsSystem = a.IsSystem, CheckLists = _mapper.Map>(checklistForActivity) }; - }).ToList() - }).ToList() + }).OrderBy(a => a.ActivityName).ToList() + }).OrderBy(ag => ag.Name).ToList() }; return response; - }).ToList(); + }).OrderBy(s => s.Name).ToList(); _logger.LogInfo("Successfully processed and mapped {ServiceCount} services for TenantId: {TenantId}", Vm.Count, tenantId); @@ -836,6 +837,7 @@ namespace Marco.Pms.Services.Service var activityGroups = await activityGroupQuery .Select(ag => _mapper.Map(ag)) + .OrderBy(ag => ag.Name) .ToListAsync(); _logger.LogInfo("{Count} activity group(s) fetched for tenantId: {TenantId}", activityGroups.Count, tenantId); @@ -1032,7 +1034,7 @@ namespace Marco.Pms.Services.Service #endregion #region =================================================================== Activity APIs =================================================================== - public async Task> GetActivitiesMasterAsync(Guid? activityGroupId, Employee loggedInEmployee, Guid tenantId) + public async Task> GetActivitiesMasterAsync(Guid? activityGroupId, string? searchString, Employee loggedInEmployee, Guid tenantId) { _logger.LogInfo("GetActivitiesMaster called"); @@ -1050,6 +1052,11 @@ namespace Marco.Pms.Services.Service activityQuery = activityQuery.Where(a => a.ActivityGroupId == activityGroupId); } + if (!string.IsNullOrWhiteSpace(searchString)) + { + activityQuery = activityQuery.Where(a => a.ActivityName.Contains(searchString)); + } + var activities = await activityQuery .ToListAsync(); @@ -1081,7 +1088,7 @@ namespace Marco.Pms.Services.Service response.CheckLists = _mapper.Map>(checklistForActivity); return response; - }).ToList(); + }).OrderBy(a => a.ActivityName).ToList(); _logger.LogInfo("{Count} activity records fetched successfully for tenantId: {TenantId}", activityVMs.Count, tenantId); diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IMasterService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IMasterService.cs index 889c8fb..30e4b9e 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IMasterService.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IMasterService.cs @@ -26,10 +26,12 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> GetPurchaseInvoiceStatusAsync(Employee loggedInEmployee, CancellationToken cancellationToken); #endregion + #region =================================================================== Invoice Attachment Type APIs =================================================================== Task> GetInvoiceAttachmentTypeAsync(Employee loggedInEmployee, CancellationToken cancellationToken); #endregion + #region =================================================================== Currency APIs =================================================================== Task> GetCurrencyAsync(Employee loggedInEmployee, Guid tenantId); @@ -58,7 +60,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces #endregion #region =================================================================== Activity APIs =================================================================== - Task> GetActivitiesMasterAsync(Guid? activityGroupId, Employee loggedInEmployee, Guid tenantId); + Task> GetActivitiesMasterAsync(Guid? activityGroupId, string? searchString, Employee loggedInEmployee, Guid tenantId); Task> CreateActivityAsync(CreateActivityMasterDto createActivity, Employee loggedInEmployee, Guid tenantId); Task> UpdateActivityAsync(Guid id, CreateActivityMasterDto createActivity, Employee loggedInEmployee, Guid tenantId); Task> DeleteActivityAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId); From 9c5df63134fad44bebd951a5a4b15a5935ffd8eb Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Wed, 10 Dec 2025 10:42:05 +0530 Subject: [PATCH 5/6] Added an API to get the attendance overview project-wise --- .../DashBoard/ProjectAttendanceOverviewVM.cs | 10 ++ .../Controllers/DashboardController.cs | 117 ++++++++++++++++++ Marco.Pms.Services/Service/ExpensesService.cs | 5 + Marco.Pms.Services/Service/ProjectServices.cs | 6 +- 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 Marco.Pms.Model/ViewModels/DashBoard/ProjectAttendanceOverviewVM.cs diff --git a/Marco.Pms.Model/ViewModels/DashBoard/ProjectAttendanceOverviewVM.cs b/Marco.Pms.Model/ViewModels/DashBoard/ProjectAttendanceOverviewVM.cs new file mode 100644 index 0000000..3a9d65a --- /dev/null +++ b/Marco.Pms.Model/ViewModels/DashBoard/ProjectAttendanceOverviewVM.cs @@ -0,0 +1,10 @@ +namespace Marco.Pms.Model.ViewModels.DashBoard +{ + public class ProjectAttendanceOverviewVM + { + public Guid ProjectId { get; set; } + public string? ProjectName { get; set; } + public int TeamCount { get; set; } + public int AttendanceCount { get; set; } + } +} diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index 905059d..e6932aa 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -1707,5 +1707,122 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(response, "job progression fetched successfully", 200)); } + + [HttpGet("project/attendance-overview")] + public async Task GetProjectAttendanceOverViewAsync([FromQuery] DateTime? date, CancellationToken cancellationToken) + { + // 1. Validation and Setup + if (tenantId == Guid.Empty) + { + _logger.LogWarning("GetProjectAttendanceOverView: Invalid request - TenantId is empty."); + return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); + } + + // Default to UTC Today if null, ensuring only Date component is used + var targetDate = date?.Date ?? DateTime.UtcNow.Date; + + _logger.LogInfo("GetProjectAttendanceOverView: Starting fetch for Tenant {TenantId} on Date {Date}", tenantId, targetDate); + + try + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + if (loggedInEmployee == null) + { + _logger.LogWarning("GetProjectAttendanceOverView: Employee not found for current user."); + return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "Employee profile not found.", 401)); + } + + // 2. Permission Check + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, loggedInEmployee.Id); + + // 3. Determine Scope of Projects (Filtering Project IDs) + // We select only the IDs first to keep the memory footprint low before aggregation + var projectQuery = _context.ProjectAllocations + .AsNoTracking() + .Where(pa => pa.TenantId == tenantId && pa.IsActive); + + if (!hasPermission) + { + // If no admin permission, restrict to projects the employee is allocated to + projectQuery = projectQuery.Where(pa => pa.EmployeeId == loggedInEmployee.Id); + } + + var visibleProjectIds = await projectQuery + .Select(pa => pa.ProjectId) + .Distinct() + .ToListAsync(cancellationToken); + + if (!visibleProjectIds.Any()) + { + return Ok(ApiResponse>.SuccessResponse(new List(), "No projects found.", 200)); + } + + // 4. Parallel Data Fetching (Optimization) + // We fetch Project Details/Allocations AND Attendance counts separately to avoid complex Cartesian products in SQL + + // Query A: Get Project Details and Total Allocation Counts + var projectsTask = _context.ProjectAllocations + .AsNoTracking() + .Where(pa => pa.TenantId == tenantId && + pa.IsActive && + visibleProjectIds.Contains(pa.ProjectId) && + pa.Project != null) + .GroupBy(pa => new { pa.ProjectId, pa.Project!.Name }) + .Select(g => new + { + ProjectId = g.Key.ProjectId, + Name = g.Key.Name, + TeamCount = g.Count() + }) + .ToListAsync(cancellationToken); + + // Query B: Get Attendance Counts for the specific date + await using var context = await _dbContextFactory.CreateDbContextAsync(); + var attendanceTask = context.Attendes + .AsNoTracking() + .Where(a => a.TenantId == tenantId && + visibleProjectIds.Contains(a.ProjectID) && + a.AttendanceDate.Date == targetDate) + .GroupBy(a => a.ProjectID) + .Select(g => new + { + ProjectId = g.Key, + Count = g.Count() + }) + .ToDictionaryAsync(k => k.ProjectId, v => v.Count, cancellationToken); + + await Task.WhenAll(projectsTask, attendanceTask); + + var projects = await projectsTask; + var attendanceMap = await attendanceTask; + + // 5. In-Memory Projection + // Merging the two datasets efficiently + var response = projects.Select(p => new ProjectAttendanceOverviewVM + { + ProjectId = p.ProjectId, + ProjectName = p.Name, + TeamCount = p.TeamCount, + // O(1) Lookup from the dictionary + AttendanceCount = attendanceMap.ContainsKey(p.ProjectId) ? attendanceMap[p.ProjectId] : 0 + }) + .OrderBy(p => p.ProjectName) + .ToList(); + + _logger.LogInfo("GetProjectAttendanceOverView: Successfully fetched {Count} projects for Tenant {TenantId}", response.Count, tenantId); + + return Ok(ApiResponse>.SuccessResponse(response, "Attendance overview fetched successfully", 200)); + } + catch (Exception ex) + { + _logger.LogError(ex, "GetProjectAttendanceOverView: An unexpected error occurred for Tenant {TenantId}", tenantId); + // Do not expose raw Exception details to client in production + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while processing your request.", 500)); + } + } + } } diff --git a/Marco.Pms.Services/Service/ExpensesService.cs b/Marco.Pms.Services/Service/ExpensesService.cs index 3f988f8..0242c9b 100644 --- a/Marco.Pms.Services/Service/ExpensesService.cs +++ b/Marco.Pms.Services/Service/ExpensesService.cs @@ -153,6 +153,11 @@ namespace Marco.Pms.Services.Service .Include(e => e.Currency) .Where(e => e.TenantId == tenantId); // Always filter by TenantId first. + //using var scope = _serviceScopeFactory.CreateScope(); + //var _projectServices = scope.ServiceProvider.GetRequiredService(); + + //var allprojectIds = await _projectServices.GetBothProjectIdsAsync(loggedInEmployee.Id, tenantId); + if (cacheList == null) { //await _cache.AddExpensesListToCache(expenses: await expensesQuery.ToListAsync(), tenantId); diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 48e2cc1..760cc6b 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -304,7 +304,9 @@ namespace Marco.Pms.Services.Service responseVms = responseVms .OrderBy(p => p.Name) .Skip((pageNumber - 1) * pageSize) - .Take(pageSize).ToList(); + .Take(pageSize) + .OrderBy(p => p.ShortName) + .ToList(); // --- Step 4: Return the combined result --- @@ -3267,7 +3269,7 @@ namespace Marco.Pms.Services.Service } } - return finalViewModels; + return finalViewModels.OrderBy(p => p.Name).ToList(); } private async Task GetProjectViewModel(Guid? id, Project project) { From 0161c9be83c5d83de27046a67413314332cf18b9 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Wed, 10 Dec 2025 14:28:29 +0530 Subject: [PATCH 6/6] Fixed the bug where able create employee with same emails --- .../Controllers/EmployeeController.cs | 460 +++++++++++------- 1 file changed, 279 insertions(+), 181 deletions(-) diff --git a/Marco.Pms.Services/Controllers/EmployeeController.cs b/Marco.Pms.Services/Controllers/EmployeeController.cs index 2496fe7..f7a6528 100644 --- a/Marco.Pms.Services/Controllers/EmployeeController.cs +++ b/Marco.Pms.Services/Controllers/EmployeeController.cs @@ -607,202 +607,156 @@ namespace MarcoBMS.Services.Controllers return Ok(ApiResponse.SuccessResponse("Success.", responsemessage, 200)); } + /// + /// Manages employee creation or update operations within the current tenant context. + /// Supports both new employee onboarding and existing employee profile modifications. + /// Enforces tenant isolation, seat limits, email uniqueness, and application access validation. + /// + /// Employee data containing creation or update information. + /// Employee view model on success or structured error response. + /// Employee created or updated successfully. + /// Invalid request data or business rule violations. + /// Employee not found for update operation. + /// Business constraint violation (duplicate email, seat limit exceeded). + /// Internal server error during database operations. [HttpPost("manage")] - public async Task CreateEmployeeAsync([FromBody] CreateUserDto model) + public async Task ManageEmployeeFromWebAsync([FromBody] CreateUserDto model) { - // Correlation and context capture for logs + // Correlation ID for distributed tracing across services and logs + var correlationId = HttpContext.TraceIdentifier; + + _logger.LogInfo("ManageEmployeeFromWebAsync started. TenantId: {TenantId}, CorrelationId: {CorrelationId}, IsUpdate: {IsUpdate}", + tenantId, correlationId, model.Id.HasValue); + + // 1. EARLY GUARD CLAUSES - Fail fast with structured validation + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Invalid tenant context. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); + return BadRequest(ApiResponse.ErrorResponse("Invalid tenant", "Valid tenant context is required for employee management", 400)); + } + + if (model == null) + { + _logger.LogWarning("Null model received. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); + return BadRequest(ApiResponse.ErrorResponse("Invalid payload", "Employee data is required in request body", 400)); + } + + // Application access requires valid email (business rule enforcement) + if (model.HasApplicationAccess && string.IsNullOrWhiteSpace(model.Email)) + { + _logger.LogWarning("Application access requested without email. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); + return BadRequest(ApiResponse.ErrorResponse("Missing email", "Application users must have a valid email address", 400)); + } + + // 2. AUTHENTICATED USER CONTEXT (single query, cached in UserHelper) var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + // 3. ENTERPRISE-GRADE ERROR HANDLING WITH TRANSACTION SCOPING + await using var transaction = await _context.Database.BeginTransactionAsync(); + try { - if (model == null) + ApiResponse response; + + if (model.Id.HasValue && model.Id != Guid.Empty) { - _logger.LogWarning("Model is null in CreateEmployeeAsync"); - return BadRequest(ApiResponse.ErrorResponse("Invalid payload", "Request body is required", 400)); + // UPDATE PATH: Validate existence first (AsNoTracking for read-only check) + _logger.LogDebug("Processing employee update. EmployeeId: {EmployeeId}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", + model.Id, tenantId, correlationId); + + var existingEmployee = await _context.Employees + .AsNoTracking() + .FirstOrDefaultAsync(e => e.Id == model.Id && e.TenantId == tenantId); + + if (existingEmployee == null) + { + _logger.LogWarning("Employee not found for update. EmployeeId: {EmployeeId}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", + model.Id, tenantId, correlationId); + return NotFound(ApiResponse.ErrorResponse("Employee not found", $"Employee with ID {model.Id} not found in tenant {tenantId}", 404)); + } + + // Track application access state change for audit + bool oldHasApplicationAccess = existingEmployee.HasApplicationAccess; + _mapper.Map(model, existingEmployee); + + response = await UpdateEmployeeAsync(oldHasApplicationAccess, existingEmployee, loggedInEmployee); + } + else + { + // CREATE PATH: New employee onboarding + _logger.LogDebug("Processing new employee creation. Email: {Email}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", + model.Email ?? "", tenantId, correlationId); + + var newEmployee = _mapper.Map(model); + newEmployee.IsSystem = false; + newEmployee.IsActive = true; + newEmployee.IsPrimary = false; + + response = await CreateEmployeeAsync(newEmployee, loggedInEmployee); } - // Basic validation - if (model.HasApplicationAccess && string.IsNullOrWhiteSpace(model.Email)) - { - _logger.LogWarning("Application access requested but email is missing"); - return BadRequest(ApiResponse.ErrorResponse("Invalid email", "Application users must have a valid email", 400)); - } - - await using var transaction = await _context.Database.BeginTransactionAsync(); - try - { - // Load existing employee if updating, constrained by organization scope - Employee? existingEmployee = null; - if (model.Id.HasValue && model.Id.Value != Guid.Empty) - { - existingEmployee = await _context.Employees - .FirstOrDefaultAsync(e => e.Id == model.Id); - if (existingEmployee == null) - { - _logger.LogInfo("Employee not found for update. Id={EmployeeId}", model.Id); - return NotFound(ApiResponse.ErrorResponse("Employee not found", "Employee not found in database", 404)); - } - } - - // Identity user creation path (only if needed) - ApplicationUser? identityUserToCreate = null; - ApplicationUser? createdIdentityUser = null; - - if (model.HasApplicationAccess) - { - // Only attempt identity resolution/creation if email supplied and either: - // - Creating new employee, or - // - Updating but existing employee does not have ApplicationUserId - var needsIdentity = string.IsNullOrWhiteSpace(existingEmployee?.ApplicationUserId); - if (needsIdentity && !string.IsNullOrWhiteSpace(model.Email)) - { - var existingUser = await _userManager.FindByEmailAsync(model.Email); - if (existingUser == null) - { - // Seat check only when provisioning a new identity user - var isSeatsAvailable = await _generalHelper.CheckSeatsRemainingAsync(tenantId); - if (!isSeatsAvailable) - { - _logger.LogWarning("Maximum users reached for Tenant {TenantId}. Cannot create identity user for {Email}", tenantId, model.Email); - return BadRequest(ApiResponse.ErrorResponse( - "Maximum number of users reached. Cannot add new user", - "Maximum number of users reached. Cannot add new user", 400)); - } - - identityUserToCreate = new ApplicationUser - { - UserName = model.Email, - Email = model.Email, - EmailConfirmed = true - }; - } - else - { - // If identity exists, re-use it; do not re-create - createdIdentityUser = existingUser; - } - } - } - - // For create path: enforce uniqueness of employee email if applicable to business rules - // Consider adding a unique filtered index: (OrganizationId, Email) WHERE Email IS NOT NULL - if (!model.Id.HasValue || model.Id == Guid.Empty) - { - if (!string.IsNullOrWhiteSpace(model.Email)) - { - var emailExists = await _context.Employees - .AnyAsync(e => e.Email == model.Email); - if (emailExists) - { - _logger.LogInfo("Employee email already exists. Email={Email}", model.Email); - return StatusCode(403, ApiResponse.ErrorResponse( - "Employee with email already exists", - "Employee with this email already exists", 403)); - } - } - } - - // Create identity user if needed - if (identityUserToCreate != null && !string.IsNullOrWhiteSpace(identityUserToCreate.Email)) - { - var createResult = await _userManager.CreateAsync(identityUserToCreate, "User@123"); - if (!createResult.Succeeded) - { - _logger.LogWarning("Failed to create identity user for {Email}. Errors={Errors}", - identityUserToCreate.Email, - string.Join(", ", createResult.Errors.Select(e => $"{e.Code}:{e.Description}"))); - return BadRequest(ApiResponse.ErrorResponse("Failed to create user", createResult.Errors, 400)); - } - - createdIdentityUser = identityUserToCreate; - _logger.LogInfo("Identity user created. IdentityUserId={UserId}, Email={Email}", - createdIdentityUser.Id, createdIdentityUser.Email); - } - - - Guid employeeId; - EmployeeVM employeeVM; - string responseMessage; - - if (existingEmployee != null) - { - // Update flow - _mapper.Map(model, existingEmployee); - - if (createdIdentityUser != null && !string.IsNullOrWhiteSpace(createdIdentityUser.Email)) - { - existingEmployee.ApplicationUserId = createdIdentityUser.Id; - await SendResetIfApplicableAsync(createdIdentityUser, existingEmployee.FirstName ?? "User"); - } - - await _context.SaveChangesAsync(); - - employeeId = existingEmployee.Id; - employeeVM = _mapper.Map(existingEmployee); - responseMessage = "Employee Updated Successfully"; - - _logger.LogInfo("Employee updated. EmployeeId={EmployeeId}, Org={OrgId}", employeeId, existingEmployee.OrganizationId); - } - else - { - // Create flow - var newEmployee = _mapper.Map(model); - newEmployee.IsSystem = false; - newEmployee.IsActive = true; - newEmployee.IsPrimary = false; - - if (createdIdentityUser != null && !string.IsNullOrWhiteSpace(createdIdentityUser.Email)) - { - newEmployee.ApplicationUserId = createdIdentityUser.Id; - await SendResetIfApplicableAsync(createdIdentityUser, newEmployee.FirstName ?? "User"); - } - - await _context.Employees.AddAsync(newEmployee); - await _context.SaveChangesAsync(); - - employeeId = newEmployee.Id; - employeeVM = _mapper.Map(newEmployee); - responseMessage = "Employee Created Successfully"; - - _logger.LogInfo("Employee created. EmployeeId={EmployeeId}, Org={OrgId}", employeeId, newEmployee.OrganizationId); - } - - await transaction.CommitAsync(); - - // SignalR notification - var notification = new - { - LoggedInUserId = loggedInEmployee.Id, - Keyword = "Employee", - EmployeeId = employeeId - }; - - // Consider broadcasting to tenant/organization group instead of Clients.All to avoid cross-tenant leaks: - // await _signalR.Clients.Group($"org:{model.OrganizationId}").SendAsync("NotificationEventHandler", notification); - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - - _logger.LogInfo("Notification broadcasted for EmployeeId={EmployeeId}", employeeId); - - return Ok(ApiResponse.SuccessResponse(employeeVM, responseMessage, 200)); - } - catch (DbUpdateException dbEx) + // 4. BUSINESS LOGIC SUCCESS VALIDATION + if (!response.Success) { + _logger.LogWarning("Business logic failed during employee management. TenantId: {TenantId}, CorrelationId: {CorrelationId}, Error: {Error}", + tenantId, correlationId, response.Message); await transaction.RollbackAsync(); - _logger.LogError(dbEx, "Database exception occurred while managing employee"); - return StatusCode(500, ApiResponse.ErrorResponse( - "Internal exception occurred", - "Internal database exception has occurred", 500)); + return StatusCode(response.StatusCode, response); } - catch (Exception ex) + + // 5. COMMIT TRANSACTION AND TENANT-SCOPED NOTIFICATION + await transaction.CommitAsync(); + + _logger.LogInfo("Employee operation completed successfully. EmployeeId: {EmployeeId}, Operation: {Operation}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", + response.Data.Id, (model.Id.HasValue ? "Update" : "Create"), tenantId, correlationId); + + // Tenant-scoped SignalR notification (prevents cross-tenant leakage) + var notification = new { - await transaction.RollbackAsync(); - _logger.LogError(ex, "Unhandled exception occurred while managing employee"); - return StatusCode(500, ApiResponse.ErrorResponse( - "Internal exception occurred", - "Internal exception has occurred", 500)); - } + TenantId = tenantId, + LoggedInUserId = loggedInEmployee.Id, + Keyword = "Employee", + EmployeeId = response.Data.Id + }; + + await _signalR.Clients.All + .SendAsync("NotificationEventHandler", notification); + + + _logger.LogDebug("Tenant-scoped SignalR notification sent. EmployeeId: {EmployeeId}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", + response.Data.Id, tenantId, correlationId); + + return StatusCode(response.StatusCode, response); + } + catch (DbUpdateConcurrencyException dbConcurrencyEx) + { + await transaction.RollbackAsync(); + _logger.LogError(dbConcurrencyEx, "Concurrency conflict during employee management. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + return Conflict(ApiResponse.ErrorResponse("Concurrency conflict", "Employee data was modified by another user. Please refresh and try again.", 409)); + } + catch (DbUpdateException dbEx) + { + await transaction.RollbackAsync(); + _logger.LogError(dbEx, "Database constraint violation during employee management. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + return StatusCode(500, ApiResponse.ErrorResponse("Database error", "Database constraint violation occurred. Please check data and try again.", 500)); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Request cancelled during employee management. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + return StatusCode(499, ApiResponse.ErrorResponse("Request cancelled", "Request was cancelled by client", 499)); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Unexpected error during employee management. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + return StatusCode(500, ApiResponse.ErrorResponse("Internal server error", "An unexpected error occurred. Please contact support if issue persists.", 500)); } } + [HttpPost("manage-mobile")] public async Task CreateUserMoblie([FromBody] MobileUserManageDto model) { @@ -1264,5 +1218,149 @@ namespace MarcoBMS.Services.Controllers await _emailSender.SendResetPasswordEmailOnRegister(u.Email ?? "", firstName, resetLink); _logger.LogInfo("Reset password email queued. Email={Email}", u.Email ?? ""); } + + private static string? CapitalizeFirst(string? text) + { + if (string.IsNullOrEmpty(text)) + return text; + + return char.ToUpper(text[0]) + text.Substring(1); + } + + /// + /// Creates an employee in the database. + /// + /// The employee to create. + /// The currently logged in employee. + /// An ApiResponse containing the created employee or an error response. + private async Task> CreateEmployeeAsync(Employee employee, Employee loggedInEmployee) + { + // Check if the employee has application access and email is provided + if (employee.HasApplicationAccess && !string.IsNullOrWhiteSpace(employee.Email)) + { + // Check if the email already exists in the database + var emailExists = await _context.Employees.AsNoTracking().AnyAsync(e => e.Email == employee.Email && e.HasApplicationAccess); + if (emailExists) + { + _logger.LogWarning("Email already exists in database. Email={Email}", employee.Email); + return ApiResponse.ErrorResponse("Email already exists", "Email already exists in database", 409); + } + + // Check if the user with the email already exists in the identity system + var user = await _userManager.FindByEmailAsync(employee.Email); + if (user == null) + { + // Create a new identity user if the user does not exist + var newUser = new ApplicationUser + { + UserName = employee.Email, + Email = employee.Email, + EmailConfirmed = true + }; + + var createResult = await _userManager.CreateAsync(newUser, "User@123"); + if (!createResult.Succeeded) + { + _logger.LogWarning("Failed to create identity user for {Email}. Errors={Errors}", + newUser.Email!, + string.Join(", ", createResult.Errors.Select(e => $"{e.Code}:{e.Description}"))); + return ApiResponse.ErrorResponse("Failed to create user", createResult.Errors, 400); + } + user = newUser; + } + + if (user == null) + { + _logger.LogWarning("User not found for {Email}", employee.Email ?? ""); + return ApiResponse.ErrorResponse("User not found", "User not found in database", 400); + } + + // Set the application user ID for the employee + employee.ApplicationUserId = user.Id; + + // Send a password reset if applicable + await SendResetIfApplicableAsync(user, employee.FirstName ?? "User"); + } + + // Add the employee to the database + await _context.Employees.AddAsync(employee); + await _context.SaveChangesAsync(); + + // Map the employee to a view model + var employeeVM = _mapper.Map(employee); + + // Return a success response with the created employee + return ApiResponse.SuccessResponse(employeeVM, "Employee Created Successfully", 201); + } + + /// + /// Updates an employee in the database. + /// + /// Whether the employee previously had application access. + /// The employee to update. + /// The currently logged in employee. + /// An ApiResponse containing the updated employee view model. + private async Task> UpdateEmployeeAsync(bool oldHasApplicationAccess, Employee employee, Employee loggedInEmployee) + { + // Check if the employee is gaining application access and has an email + if (!oldHasApplicationAccess && employee.HasApplicationAccess && !string.IsNullOrWhiteSpace(employee.Email)) + { + // Check if the email already exists in the database + var emailExists = await _context.Employees.AsNoTracking().FirstOrDefaultAsync(e => e.Email == employee.Email && e.Id != employee.Id && e.HasApplicationAccess); + + if (emailExists != null) + { + // Log warning and return error response if email already exists + _logger.LogWarning("Email already exists in database. Email={Email}", employee.Email ?? ""); + return ApiResponse.ErrorResponse("Email already exists", "Email already exists in database", 409); + } + + // Check if the user with the email already exists in the identity system + var user = await _userManager.FindByEmailAsync(employee.Email); + if (user == null) + { + // Create a new user with the email in the identity system + var newUser = new ApplicationUser + { + UserName = employee.Email, + Email = employee.Email, + EmailConfirmed = true + }; + + var createResult = await _userManager.CreateAsync(newUser, "User@123"); + if (!createResult.Succeeded) + { + // Log warning and return error response if user creation failed + _logger.LogWarning("Failed to create identity user for {Email}. Errors={Errors}", + newUser.Email!, + string.Join(", ", createResult.Errors.Select(e => $"{e.Code}:{e.Description}"))); + return ApiResponse.ErrorResponse("Failed to create user", createResult.Errors, 400); + } + user = newUser; + } + + if (user == null) + { + // Log warning and return error response if user not found + _logger.LogWarning("User not found for {Email}", employee.Email ?? ""); + return ApiResponse.ErrorResponse("User not found", "User not found in database", 400); + } + + // Set the application user id on the employee + employee.ApplicationUserId = user.Id; + + // Send a password reset email if applicable + await SendResetIfApplicableAsync(user, employee.FirstName ?? "User"); + } + + // Update the employee in the database + _context.Employees.Update(employee); + await _context.SaveChangesAsync(); + + // Map the employee to a view model and return a success response + var employeeVM = _mapper.Map(employee); + return ApiResponse.SuccessResponse(employeeVM, "Employee Updated Successfully", 200); + } + } }