From fbb8a2261b37549d487b31fe01971a77702095be Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Fri, 14 Nov 2025 15:17:29 +0530 Subject: [PATCH] Added an API to update the job ticket --- .../ServiceProject/CreateJobTicketDto.cs | 2 +- .../Dtos/ServiceProject/UpdateJobTicketDto.cs | 17 + .../Controllers/ServiceProjectController.cs | 61 ++- .../MappingProfiles/MappingProfile.cs | 4 + Marco.Pms.Services/Marco.Pms.Services.csproj | 1 + Marco.Pms.Services/Program.cs | 2 +- .../ServiceInterfaces/IServiceProject.cs | 7 +- .../Service/ServiceProjectService.cs | 369 ++++++++++++++++-- 8 files changed, 419 insertions(+), 44 deletions(-) rename Marco.Pms.Model/{ViewModels => Dtos}/ServiceProject/CreateJobTicketDto.cs (90%) create mode 100644 Marco.Pms.Model/Dtos/ServiceProject/UpdateJobTicketDto.cs diff --git a/Marco.Pms.Model/ViewModels/ServiceProject/CreateJobTicketDto.cs b/Marco.Pms.Model/Dtos/ServiceProject/CreateJobTicketDto.cs similarity index 90% rename from Marco.Pms.Model/ViewModels/ServiceProject/CreateJobTicketDto.cs rename to Marco.Pms.Model/Dtos/ServiceProject/CreateJobTicketDto.cs index 8ef75cf..b3bb9a8 100644 --- a/Marco.Pms.Model/ViewModels/ServiceProject/CreateJobTicketDto.cs +++ b/Marco.Pms.Model/Dtos/ServiceProject/CreateJobTicketDto.cs @@ -1,7 +1,7 @@ using Marco.Pms.Model.Dtos.Employees; using Marco.Pms.Model.Dtos.Master; -namespace Marco.Pms.Model.ViewModels.ServiceProject +namespace Marco.Pms.Model.Dtos.ServiceProject { public class CreateJobTicketDto { diff --git a/Marco.Pms.Model/Dtos/ServiceProject/UpdateJobTicketDto.cs b/Marco.Pms.Model/Dtos/ServiceProject/UpdateJobTicketDto.cs new file mode 100644 index 0000000..d142638 --- /dev/null +++ b/Marco.Pms.Model/Dtos/ServiceProject/UpdateJobTicketDto.cs @@ -0,0 +1,17 @@ +using Marco.Pms.Model.Dtos.Employees; +using Marco.Pms.Model.Dtos.Master; + +namespace Marco.Pms.Model.Dtos.ServiceProject +{ + public class UpdateJobTicketDto + { + public string? Title { get; set; } + public string? Description { get; set; } + public Guid ProjectId { get; set; } + public Guid StatusId { get; set; } + public List? Assignees { get; set; } + public DateTime StartDate { get; set; } + public DateTime DueDate { get; set; } + public List? Tags { get; set; } + } +} diff --git a/Marco.Pms.Services/Controllers/ServiceProjectController.cs b/Marco.Pms.Services/Controllers/ServiceProjectController.cs index bdeb2bc..1e3d357 100644 --- a/Marco.Pms.Services/Controllers/ServiceProjectController.cs +++ b/Marco.Pms.Services/Controllers/ServiceProjectController.cs @@ -1,10 +1,11 @@ -using Marco.Pms.Model.Dtos.ServiceProject; +using AutoMapper; +using Marco.Pms.Model.Dtos.ServiceProject; using Marco.Pms.Model.Employees; -using Marco.Pms.Model.ViewModels.ServiceProject; +using Marco.Pms.Model.Utilities; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; -using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; namespace Marco.Pms.Services.Controllers @@ -15,15 +16,18 @@ namespace Marco.Pms.Services.Controllers public class ServiceProjectController : Controller { private readonly IServiceProject _serviceProject; + private readonly IServiceScopeFactory _serviceScopeFactory; private readonly UserHelper _userHelper; - private readonly ILoggingService _logger; private readonly ISignalRService _signalR; private readonly Guid tenantId; - public ServiceProjectController(IServiceProject serviceProject, UserHelper userHelper, ILoggingService logger, ISignalRService signalR) + public ServiceProjectController(IServiceProject serviceProject, + IServiceScopeFactory serviceScopeFactory, + UserHelper userHelper, + ISignalRService signalR) { _serviceProject = serviceProject; + _serviceScopeFactory = serviceScopeFactory; _userHelper = userHelper; - _logger = logger; _signalR = signalR; tenantId = userHelper.GetTenantId(); @@ -156,6 +160,51 @@ namespace Marco.Pms.Services.Controllers return StatusCode(response.StatusCode, response); } + [HttpPatch("job/edit/{id}")] + public async Task UpdateJobTicket(Guid id, JsonPatchDocument patchDoc) + { + // Validate incoming patch document + if (patchDoc == null) + { + return BadRequest(ApiResponse.ErrorResponse("Invalid request", "Patch document cannot be null", 400)); + } + + // Get the currently logged in employee + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Retrieve the job ticket by id and tenant context + var jobTicket = await _serviceProject.GetJobTicketByIdAsync(id, tenantId); + if (jobTicket == null) + { + return NotFound(ApiResponse.ErrorResponse("Job ticket not found", $"No active job ticket found with id {id}", 404)); + } + + // Use a scoped Mapper instance to map the entity to the DTO for patching + using var scope = _serviceScopeFactory.CreateScope(); + var mapper = scope.ServiceProvider.GetRequiredService(); + var modelToPatch = mapper.Map(jobTicket); + + // Apply the JSON Patch document to the DTO and check model state validity + patchDoc.ApplyTo(modelToPatch, ModelState); + if (!ModelState.IsValid) + { + return BadRequest(ApiResponse.ErrorResponse("Validation failed", "Provided patch document values are invalid", 400)); + } + + // Update the job ticket with the patched model and logged-in user info + var response = await _serviceProject.UpdateJobTicketAsync(id, jobTicket, modelToPatch, loggedInEmployee, tenantId); + if (response.Success) + { + // If update successful, send SignalR notification with relevant info + var notificationPayload = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Job_Ticket", Response = response.Data }; + await _signalR.SendNotificationAsync(notificationPayload); + } + + // Return status codes with appropriate response + return StatusCode(response.StatusCode, response); + } + + [HttpPost("job/add/comment")] public async Task AddCommentToJobTicket(JobCommentDto model) { diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 744e8e3..4278a27 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -198,8 +198,11 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); CreateMap(); + #region ======================================================= Job Ticket ======================================================= CreateMap(); + CreateMap(); + CreateMap(); CreateMap(); CreateMap(); CreateMap() @@ -210,6 +213,7 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); #endregion + #endregion #region ======================================================= Employee ======================================================= diff --git a/Marco.Pms.Services/Marco.Pms.Services.csproj b/Marco.Pms.Services/Marco.Pms.Services.csproj index c248667..fa28c50 100644 --- a/Marco.Pms.Services/Marco.Pms.Services.csproj +++ b/Marco.Pms.Services/Marco.Pms.Services.csproj @@ -18,6 +18,7 @@ + diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 7b84096..ac3cd71 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -72,7 +72,7 @@ builder.Services.AddCors(options => #endregion #region Core Web & Framework Services -builder.Services.AddControllers(); +builder.Services.AddControllers().AddNewtonsoftJson(); builder.Services.AddSignalR(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddHttpContextAccessor(); diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs index 1118483..65773a7 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs @@ -1,7 +1,7 @@ using Marco.Pms.Model.Dtos.ServiceProject; using Marco.Pms.Model.Employees; +using Marco.Pms.Model.ServiceProject; using Marco.Pms.Model.Utilities; -using Marco.Pms.Model.ViewModels.ServiceProject; namespace Marco.Pms.Services.Service.ServiceInterfaces { @@ -22,8 +22,13 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> GetJobTagListAsync(Employee loggedInEmployee, Guid tenantId); Task> CreateJobTicketAsync(CreateJobTicketDto model, Employee loggedInEmployee, Guid tenantId); Task> ChangeJobsStatusAsync(ChangeJobStatusDto model, Employee loggedInEmployee, Guid tenantId); + Task> UpdateJobTicketAsync(Guid id, JobTicket jobTicket, UpdateJobTicketDto model, Employee loggedInEmployee, Guid tenantId); Task> AddCommentToJobTicketAsync(JobCommentDto model, Employee loggedInEmployee, Guid tenantId); #endregion + + #region =================================================================== Pubic Helper Functions =================================================================== + Task GetJobTicketByIdAsync(Guid id, Guid tenantId); + #endregion } } diff --git a/Marco.Pms.Services/Service/ServiceProjectService.cs b/Marco.Pms.Services/Service/ServiceProjectService.cs index f531ff3..9815a19 100644 --- a/Marco.Pms.Services/Service/ServiceProjectService.cs +++ b/Marco.Pms.Services/Service/ServiceProjectService.cs @@ -1,9 +1,11 @@ using AutoMapper; using Marco.Pms.DataAccess.Data; +using Marco.Pms.Helpers.Utility; using Marco.Pms.Model.DocumentManager; using Marco.Pms.Model.Dtos.ServiceProject; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Master; +using Marco.Pms.Model.MongoDBModels.Utility; using Marco.Pms.Model.ServiceProject; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; @@ -14,6 +16,7 @@ using Marco.Pms.Model.ViewModels.ServiceProject; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; +using MongoDB.Bson; namespace Marco.Pms.Services.Service { @@ -867,41 +870,7 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Job Not Found", "Job ticket not found.", 404); } - // Find transition mappings for the current status and desired status, considering team role if allocation exists - var statusMappingQuery = _context.JobStatusMappings - .Include(jsm => jsm.Status) - .Include(jsm => jsm.NextStatus) - .Where(jsm => - jsm.StatusId == jobTicket.StatusId && - jsm.NextStatusId == model.StatusId && - jsm.Status != null && - jsm.NextStatus != null && - jsm.TenantId == tenantId); - - // Find allocation for current employee (to determine team role for advanced mapping) - var projectAllocation = await _context.ServiceProjectAllocations - .Where(spa => spa.EmployeeId == loggedInEmployee.Id && - spa.ProjectId == jobTicket.ProjectId && - spa.TenantId == tenantId && - spa.IsActive) - .OrderByDescending(spa => spa.AssignedAt) - .FirstOrDefaultAsync(); - - var teamRoleId = projectAllocation?.TeamRoleId; - var hasTeamRoleMapping = projectAllocation != null - && await statusMappingQuery.AnyAsync(jsm => jsm.TeamRoleId == teamRoleId); - - // Filter by team role or fallback to global (null team role) - if (hasTeamRoleMapping) - { - statusMappingQuery = statusMappingQuery.Where(jsm => jsm.TeamRoleId == teamRoleId); - } - else - { - statusMappingQuery = statusMappingQuery.Where(jsm => jsm.TeamRoleId == null); - } - - var jobStatusMapping = await statusMappingQuery.FirstOrDefaultAsync(); + var jobStatusMapping = await GetJobStatusMappingAsync(jobTicket.StatusId, model.StatusId, jobTicket.ProjectId, loggedInEmployee.Id, tenantId); if (jobStatusMapping == null) { @@ -946,6 +915,258 @@ namespace Marco.Pms.Services.Service } } + /// + /// Updates a job ticket including its core details, assignees, tags, and manages status transitions with audit logs. + /// + /// ID of the job ticket to update. + /// Existing job ticket entity to update. + /// DTO containing updated job ticket values. + /// Employee performing the update (used for audit and authorization). + /// Tenant identifier for data isolation. + /// ApiResponse containing updated job ticket data or error details. + public async Task> UpdateJobTicketAsync( + Guid id, + JobTicket jobTicket, + UpdateJobTicketDto model, + Employee loggedInEmployee, + Guid tenantId) + { + // Validate tenant context early + if (tenantId == Guid.Empty) + { + _logger.LogWarning("UpdateJobTicketAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); + } + + try + { + // Concurrently load referenced project and status entities to validate foreign keys + var projectTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.ServiceProjects.FirstOrDefaultAsync(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId && sp.IsActive); + }); + var statusTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.JobStatus.FirstOrDefaultAsync(js => js.Id == model.StatusId); + }); + + await Task.WhenAll(projectTask, statusTask); + + // Validate existence of foreign entities + if (projectTask.Result == null) + { + _logger.LogWarning("Service project not found during job ticket update. ProjectId: {ProjectId}, TenantId: {TenantId}", model.ProjectId, tenantId); + return ApiResponse.ErrorResponse("Service project not found", "Service project not found", 404); + } + if (statusTask.Result == null) + { + _logger.LogWarning("Job status not found during job ticket update. StatusId: {StatusId}", model.StatusId); + return ApiResponse.ErrorResponse("Job status not found", "Job status not found", 404); + } + + // Begin database transaction for atomicity + await using var transaction = await _context.Database.BeginTransactionAsync(); + + // Handle status change with validation and log creation + if (jobTicket.StatusId != model.StatusId) + { + var jobStatusMapping = await GetJobStatusMappingAsync(jobTicket.StatusId, model.StatusId, jobTicket.ProjectId, loggedInEmployee.Id, tenantId); + if (jobStatusMapping == null) + { + _logger.LogWarning("Invalid status transition requested from {CurrentStatusId} to {NewStatusId} in tenant {TenantId}", + jobTicket.StatusId, model.StatusId, tenantId); + return ApiResponse.ErrorResponse("Invalid Status", "Selected status transition is not allowed.", 400); + } + + var comment = $"Status changed from {jobStatusMapping.Status!.Name} to {jobStatusMapping.NextStatus!.Name}"; + var updateLog = new StatusUpdateLog + { + Id = Guid.NewGuid(), + EntityId = jobTicket.Id, + StatusId = jobStatusMapping.StatusId, + NextStatusId = jobStatusMapping.NextStatusId, + Comment = comment, + UpdatedAt = DateTime.UtcNow, + UpdatedById = loggedInEmployee.Id, + TenantId = tenantId + }; + _context.StatusUpdateLogs.Add(updateLog); + } + + // Create BSON snapshot of existing entity for audit logging (MongoDB) + using var scope = _serviceScopeFactory.CreateScope(); + var updateLogHelper = scope.ServiceProvider.GetRequiredService(); + BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(jobTicket); + + // Map updated properties from DTO, set audit metadata + _mapper.Map(model, jobTicket); + jobTicket.UpdatedAt = DateTime.UtcNow; + jobTicket.UpdatedById = loggedInEmployee.Id; + + _context.JobTickets.Update(jobTicket); + await _context.SaveChangesAsync(); + + // Handle assignee changes: add new and remove inactive mappings + if (model.Assignees?.Any() == true) + { + var employeeIds = model.Assignees.Select(e => e.EmployeeId).ToList(); + + var employeeTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.Employees.Where(e => employeeIds.Contains(e.Id)).ToListAsync(); + }); + var jobEmployeeMappingTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.JobEmployeeMappings.Where(jem => employeeIds.Contains(jem.AssigneeId) && jem.TenantId == tenantId).ToListAsync(); + }); + + await Task.WhenAll(employeeTask, jobEmployeeMappingTask); + + var employees = employeeTask.Result; + var jobEmployeeMappings = jobEmployeeMappingTask.Result; + + var newMappings = new List(); + var removeMappings = new List(); + + foreach (var assignee in model.Assignees) + { + var employee = employees.FirstOrDefault(e => e.Id == assignee.EmployeeId); + var mapping = jobEmployeeMappings.FirstOrDefault(jem => jem.AssigneeId == assignee.EmployeeId); + + if (assignee.IsActive && mapping == null && employee != null) + { + newMappings.Add(new JobEmployeeMapping + { + Id = Guid.NewGuid(), + AssigneeId = assignee.EmployeeId, + JobTicketId = jobTicket.Id, + TenantId = tenantId, + }); + } + else if (!assignee.IsActive && mapping != null) + { + removeMappings.Add(mapping); + } + } + if (newMappings.Any()) _context.JobEmployeeMappings.AddRange(newMappings); + if (removeMappings.Any()) _context.JobEmployeeMappings.RemoveRange(removeMappings); + } + await _context.SaveChangesAsync(); + + // Handle tag changes: add new tags/mappings and remove inactive mappings + if (model.Tags?.Any() == true) + { + var tagNames = model.Tags.Select(jt => jt.Name).ToList(); + + var tagTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.JobTags.Where(jt => tagNames.Contains(jt.Name) && jt.TenantId == tenantId).ToListAsync(); + }); + + var tagMappingTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.JobTagMappings.Where(jtm => jtm.JobTicketId == jobTicket.Id && jtm.TenantId == tenantId).ToListAsync(); + }); + + await Task.WhenAll(tagTask, tagMappingTask); + + var existingTags = tagTask.Result; + var existingTagMappings = tagMappingTask.Result; + + var newJobTags = new List(); + var newJobTagMappings = new List(); + var removeJobTagMappings = new List(); + + foreach (var tagDto in model.Tags) + { + var tag = existingTags.FirstOrDefault(jt => jt.Name == tagDto.Name); + if (tag == null) + { + tag = new JobTag + { + Id = Guid.NewGuid(), + Name = tagDto.Name, + TenantId = tenantId + }; + newJobTags.Add(tag); + } + var tagMapping = existingTagMappings.FirstOrDefault(jtm => jtm.JobTagId == tag.Id); + + if (tagDto.IsActive && tagMapping == null) + { + newJobTagMappings.Add(new JobTagMapping + { + Id = Guid.NewGuid(), + JobTagId = tag.Id, + JobTicketId = jobTicket.Id, + TenantId = tenantId + }); + } + else if (!tagDto.IsActive && tagMapping != null) + { + removeJobTagMappings.Add(tagMapping); + } + } + if (newJobTags.Any()) _context.JobTags.AddRange(newJobTags); + if (newJobTagMappings.Any()) _context.JobTagMappings.AddRange(newJobTagMappings); + if (removeJobTagMappings.Any()) _context.JobTagMappings.RemoveRange(removeJobTagMappings); + } + + await _context.SaveChangesAsync(); + await transaction.CommitAsync(); + + // Push update log asynchronously to MongoDB for audit + var updateLogTask = updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = existingEntityBson, + UpdatedAt = DateTime.UtcNow + }, "JobTicketModificationLog"); + + // Reload updated job ticket with navigation properties + var jobTicketTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.JobTickets + .Include(jt => jt.Status) + .Include(jt => jt.Project) + .Include(jt => jt.CreatedBy).ThenInclude(e => e!.JobRole) + .Include(jt => jt.UpdatedBy).ThenInclude(e => e!.JobRole) + .FirstOrDefaultAsync(jt => jt.Id == id && jt.TenantId == tenantId); + }); + + await Task.WhenAll(updateLogTask, jobTicketTask); + + jobTicket = jobTicketTask.Result ?? new JobTicket(); + + var response = _mapper.Map(jobTicket); + + _logger.LogInfo("Job ticket {JobTicketId} updated successfully by employee {EmployeeId} in tenant {TenantId}", id, loggedInEmployee.Id, tenantId); + + return ApiResponse.SuccessResponse(response, "Job updated successfully", 200); + } + catch (DbUpdateException dbEx) + { + _logger.LogError(dbEx, "Database error while updating job ticket for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}", + model.ProjectId, loggedInEmployee.Id, tenantId); + return ApiResponse.ErrorResponse("Database Error", "An error occurred while saving data to the database.", 500); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception while updating job ticket for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}", + model.ProjectId, loggedInEmployee.Id, tenantId); + return ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500); + } + } + + #endregion #region =================================================================== Job Comments Functions =================================================================== @@ -1195,5 +1416,83 @@ namespace Marco.Pms.Services.Service } #endregion + #region =================================================================== Pubic Helper Functions =================================================================== + + /// + /// Retrieves a job ticket by its unique identifier and associated tenant ID. + /// + /// The unique identifier of the job ticket. + /// The tenant identifier for multi-tenant isolation. + /// The job ticket if found; otherwise, null. + public async Task GetJobTicketByIdAsync(Guid id, Guid tenantId) + { + try + { + _logger.LogInfo($"Attempting to retrieve job ticket with ID: {id} for tenant: {tenantId}"); + + // Use AsNoTracking for read-only queries to improve performance + var jobTicket = await _context.JobTickets + .AsNoTracking() + .FirstOrDefaultAsync(jt => jt.Id == id && jt.TenantId == tenantId); + + if (jobTicket == null) + { + _logger.LogWarning($"Job ticket not found. ID: {id}, TenantID: {tenantId}"); + } + else + { + _logger.LogInfo($"Job ticket found. ID: {id}, TenantID: {tenantId}"); + } + + return jobTicket; + } + catch (Exception ex) + { + _logger.LogError(ex, $"An error occurred while retrieving job ticket. ID: {id}, TenantID: {tenantId}"); + // Consider whether you want to rethrow or return null on error; returning null here + return null; + } + } + + private async Task GetJobStatusMappingAsync(Guid statusId, Guid nextStatusId, Guid projectId, Guid loggedInEmployeeId, Guid tenantId) + { + // Find transition mappings for the current status and desired status, considering team role if allocation exists + var statusMappingQuery = _context.JobStatusMappings + .Include(jsm => jsm.Status) + .Include(jsm => jsm.NextStatus) + .Where(jsm => + jsm.StatusId == statusId && + jsm.NextStatusId == nextStatusId && + jsm.Status != null && + jsm.NextStatus != null && + jsm.TenantId == tenantId); + + // Find allocation for current employee (to determine team role for advanced mapping) + var projectAllocation = await _context.ServiceProjectAllocations + .Where(spa => spa.EmployeeId == loggedInEmployeeId && + spa.ProjectId == projectId && + spa.TenantId == tenantId && + spa.IsActive) + .OrderByDescending(spa => spa.AssignedAt) + .FirstOrDefaultAsync(); + + var teamRoleId = projectAllocation?.TeamRoleId; + var hasTeamRoleMapping = projectAllocation != null + && await statusMappingQuery.AnyAsync(jsm => jsm.TeamRoleId == teamRoleId); + + // Filter by team role or fallback to global (null team role) + if (hasTeamRoleMapping) + { + statusMappingQuery = statusMappingQuery.Where(jsm => jsm.TeamRoleId == teamRoleId); + } + else + { + statusMappingQuery = statusMappingQuery.Where(jsm => jsm.TeamRoleId == null); + } + + var jobStatusMapping = await statusMappingQuery.FirstOrDefaultAsync(); + return jobStatusMapping; + } + #endregion } }