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; using Marco.Pms.Model.ViewModels.AttendanceVM; using Marco.Pms.Model.ViewModels.DocumentManager; using Marco.Pms.Model.ViewModels.Master; using Marco.Pms.Model.ViewModels.Organization; 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 { public class ServiceProjectService : IServiceProject { private readonly IDbContextFactory _dbContextFactory; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate private readonly ILoggingService _logger; private readonly S3UploadService _s3Service; private readonly IMapper _mapper; private readonly Guid NewStatus = Guid.Parse("32d76a02-8f44-4aa0-9b66-c3716c45a918"); private readonly Guid AssignedStatus = Guid.Parse("cfa1886d-055f-4ded-84c6-42a2a8a14a66"); public ServiceProjectService(IDbContextFactory dbContextFactory, IServiceScopeFactory serviceScopeFactory, ApplicationDbContext context, ILoggingService logger, S3UploadService s3Service, IMapper mapper) { _serviceScopeFactory = serviceScopeFactory; _context = context; _logger = logger; _s3Service = s3Service; _mapper = mapper; _dbContextFactory = dbContextFactory; } #region =================================================================== Service Project Functions =================================================================== /// /// Retrieves a paginated list of active service projects including their clients, status, creators, and related services for a given tenant. /// /// Page number (1-based) for pagination. /// Number of records per page. /// Employee making the request (for logging). /// Tenant identifier for multi-tenant isolation. /// ApiResponse containing paged service projects with related data or error information. public async Task> GetServiceProjectListAsync(int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("GetServiceProjectListAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } if (pageNumber < 1 || pageSize < 1) { _logger.LogInfo("Invalid pagination parameters: PageNumber={PageNumber}, PageSize={PageSize}", pageNumber, pageSize); return ApiResponse.ErrorResponse("Bad Request", "Page number and size must be greater than zero.", 400); } try { // Base query for active projects scoped by tenant including necessary related entities var serviceProjectQuery = _context.ServiceProjects .Include(sp => sp.Client) .Include(sp => sp.Status) .Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole) .Where(sp => sp.TenantId == tenantId && sp.IsActive); var totalEntities = await serviceProjectQuery.CountAsync(); var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize); // Fetch paged projects ordered by creation date descending var serviceProjects = await serviceProjectQuery .OrderByDescending(sp => sp.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); var serviceProjectIds = serviceProjects.Select(sp => sp.Id).ToList(); // Load related service mappings with services for current page projects (avoid N+1) var serviceProjectServiceMappings = await _context.ServiceProjectServiceMapping .Include(sps => sps.Service) .Where(sps => serviceProjectIds.Contains(sps.ProjectId) && sps.Service != null && sps.TenantId == tenantId) .ToListAsync(); // Map each project with its related services into the view models var serviceProjectVMs = serviceProjects.Select(sp => { var relatedServices = serviceProjectServiceMappings .Where(sps => sps.ProjectId == sp.Id) .Select(sps => sps.Service!) .ToList(); var projectVm = _mapper.Map(sp); projectVm.Services = _mapper.Map>(relatedServices); return projectVm; }).ToList(); var response = new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntities = totalEntities, Data = serviceProjectVMs, }; _logger.LogInfo("Retrieved {Count} service projects for tenant {TenantId} by employee {EmployeeId}. Page {PageNumber}/{TotalPages}", serviceProjectVMs.Count, tenantId, loggedInEmployee.Id, pageNumber, totalPages); return ApiResponse.SuccessResponse(response, "Projects retrieved successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "An unexpected error occurred in GetServiceProjectListAsync for tenant {TenantId} by employee {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); } } /// /// Retrieves detailed information for a specific service project, including related client, status, services, and audit information. /// /// The unique identifier of the service project. /// The employee requesting the details (for audit logging). /// Tenant identifier ensuring multi-tenant data isolation. /// ApiResponse containing detailed service project information or error details. public async Task> GetServiceProjectDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("GetServiceProjectDetailsAsync called with missing tenant context by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } try { // Load service project with related client, status, and creator/updater roles var serviceProject = await _context.ServiceProjects .Include(sp => sp.Client) .Include(sp => sp.Status) .Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole) .Include(sp => sp.UpdatedBy).ThenInclude(e => e!.JobRole) .AsNoTracking() .FirstOrDefaultAsync(sp => sp.Id == id && sp.TenantId == tenantId); if (serviceProject == null) { _logger.LogWarning("Service project {ServiceProjectId} not found for tenant {TenantId}", id, tenantId); return ApiResponse.ErrorResponse("Service Project Not Found", "Service project not found for the specified tenant.", 404); } // Retrieve related services for the project var services = await _context.ServiceProjectServiceMapping .Include(sps => sps.Service) .Where(sps => sps.ProjectId == serviceProject.Id && sps.Service != null && sps.TenantId == tenantId) .Select(sps => sps.Service!) .ToListAsync(); // Optional: Count number of job tickets associated with the project (commented out) // var numberOfJobs = await _context.JobTickets.CountAsync(jt => jt.ProjectId == serviceProject.Id && jt.TenantId == tenantId); var response = _mapper.Map(serviceProject); response.Services = _mapper.Map>(services); response.NumberOfJobs = await _context.JobTickets.CountAsync(jt => jt.ProjectId == id && jt.IsActive && jt.TenantId == tenantId); _logger.LogInfo("Fetched details for service project {ServiceProjectId} for tenant {TenantId} requested by employee {EmployeeId}", id, tenantId, loggedInEmployee.Id); return ApiResponse.SuccessResponse(response, "Service Project details fetched successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch service project details for project {ServiceProjectId} by employee {EmployeeId} in tenant {TenantId}", id, loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse("Internal Server Error", "Unable to fetch service project details. Please try again later.", 500); } } /// /// Creates a new service project along with its associated active services, and returns the created project details. /// /// DTO containing service project information and list of active services. /// Employee creating the service project (for auditing). /// Tenant context for multi-tenancy. /// ApiResponse containing created service project details or error info. public async Task> CreateServiceProjectAsync(ServiceProjectDto model, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("CreateServiceProjectAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } if (model == null) { _logger.LogInfo("CreateServiceProjectAsync called with null model by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Bad Request", "Input model cannot be null.", 400); } try { // Extract active service IDs for validation and association var serviceIds = model.Services?.Where(s => s.IsActive).Select(s => s.ServiceId).ToList() ?? new List(); // Concurrently load dependent entities: client, services, status var clientTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Organizations.FirstOrDefaultAsync(o => o.Id == model.ClientId && o.IsActive); }); var serviceTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceMasters.Where(s => serviceIds.Contains(s.Id) && s.TenantId == tenantId && s.IsActive).ToListAsync(); }); var statusTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.StatusMasters.FirstOrDefaultAsync(s => s.Id == model.StatusId); }); await Task.WhenAll(clientTask, serviceTask, statusTask); var client = clientTask.Result; var services = serviceTask.Result; var status = statusTask.Result; // Validate existence of critical foreign entities if (client == null) { _logger.LogWarning("Client with ID {ClientId} not found or inactive in tenant {TenantId}", model.ClientId, tenantId); return ApiResponse.ErrorResponse("Client Not Found", "Client not found or inactive.", 404); } if (status == null) { _logger.LogWarning("Status with ID {StatusId} not found in tenant {TenantId}", model.StatusId, tenantId); return ApiResponse.ErrorResponse("Project Status Not Found", "Project status not found.", 404); } // Map DTO to entity and enhance with auditing and tenant info var serviceProject = _mapper.Map(model); serviceProject.Id = Guid.NewGuid(); serviceProject.CreatedById = loggedInEmployee.Id; serviceProject.CreatedAt = DateTime.UtcNow; serviceProject.IsActive = true; serviceProject.TenantId = tenantId; // Create mappings only for active services validated by database var projectServiceMappings = model.Services? .Where(sdto => services.Any(s => s.Id == sdto.ServiceId) && sdto.IsActive) .Select(sdto => new ServiceProjectServiceMapping { ServiceId = sdto.ServiceId, ProjectId = serviceProject.Id, TenantId = tenantId }).ToList() ?? new List(); // Add new project and its service mappings to context _context.ServiceProjects.Add(serviceProject); _context.ServiceProjectServiceMapping.AddRange(projectServiceMappings); // Persist data await _context.SaveChangesAsync(); _logger.LogInfo("Service Project {ProjectId} created successfully for Tenant {TenantId} by Employee {EmployeeId}", serviceProject.Id, tenantId, loggedInEmployee.Id); // Prepare view model to return, mapping related created entities var serviceProjectVM = _mapper.Map(serviceProject); serviceProjectVM.Client = _mapper.Map(client); serviceProjectVM.Status = status; serviceProjectVM.Services = services.Select(s => _mapper.Map(s)).ToList(); serviceProjectVM.CreatedBy = _mapper.Map(loggedInEmployee); return ApiResponse.SuccessResponse(serviceProjectVM, "Service project created successfully.", 201); } catch (Exception ex) { _logger.LogError(ex, "Failed to create service project for tenant {TenantId} by employee {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while creating the service project. Please try again later.", 500); } } /// /// Updates an existing active service project including its related services, and returns updated project details. /// /// The ID of the service project to update. /// DTO containing updated service project data and service list. /// Employee performing the update (for audit/logging). /// Tenant identifier for multi-tenant isolation. /// ApiResponse containing updated service project details or error info. public async Task> UpdateServiceProjectAsync(Guid id, ServiceProjectDto model, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("UpdateServiceProjectAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } if (model == null) { _logger.LogInfo("UpdateServiceProjectAsync called with null model by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Bad Request", "Input model cannot be null.", 400); } try { // Extract all service IDs from input for validation var serviceIds = model.Services?.Select(s => s.ServiceId).ToList() ?? new List(); // Concurrently fetch related entities for validation var clientTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Organizations.FirstOrDefaultAsync(o => o.Id == model.ClientId && o.IsActive); }); var serviceTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceMasters.Where(s => serviceIds.Contains(s.Id) && s.TenantId == tenantId && s.IsActive).ToListAsync(); }); var statusTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.StatusMasters.FirstOrDefaultAsync(s => s.Id == model.StatusId); }); await Task.WhenAll(clientTask, serviceTask, statusTask); var client = clientTask.Result; var services = serviceTask.Result; var status = statusTask.Result; // Validate client and status existence if (client == null) { _logger.LogWarning("Client with ID {ClientId} not found or inactive in tenant {TenantId}", model.ClientId, tenantId); return ApiResponse.ErrorResponse("Client Not Found", "Client not found or inactive.", 404); } if (status == null) { _logger.LogWarning("Project status with ID {StatusId} not found in tenant {TenantId}", model.StatusId, tenantId); return ApiResponse.ErrorResponse("Project Status Not Found", "Project status not found.", 404); } // Fetch existing service project for update, ensuring active and tenant scope var serviceProject = await _context.ServiceProjects .Where(sp => sp.Id == id && sp.TenantId == tenantId && sp.IsActive) .FirstOrDefaultAsync(); if (serviceProject == null) { _logger.LogWarning("Attempt to update non-existent service project with ID {ProjectId} by employee {EmployeeId}", id, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Project Not Found", $"No active project found with ID {id}.", 404); } // Create BSON snapshot of existing entity for audit logging (MongoDB) using var scope = _serviceScopeFactory.CreateScope(); var updateLogHelper = scope.ServiceProvider.GetRequiredService(); BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(serviceProject); // Map incoming DTO to the tracked entity _mapper.Map(model, serviceProject); serviceProject.UpdatedAt = DateTime.UtcNow; serviceProject.UpdatedById = loggedInEmployee.Id; // Get existing service mappings for the project (no tracking as we perform add/remove explicitly) var existingMappings = await _context.ServiceProjectServiceMapping .AsNoTracking() .Where(sps => sps.ProjectId == serviceProject.Id && sps.TenantId == tenantId) .ToListAsync(); // Determine service mappings to add or remove based on input DTO state var newMappings = new List(); var removedMappings = new List(); if (model.Services != null) { foreach (var dto in model.Services) { var existingMapping = existingMappings.FirstOrDefault(sps => sps.ServiceId == dto.ServiceId); if (dto.IsActive && existingMapping == null) { newMappings.Add(new ServiceProjectServiceMapping { Id = Guid.NewGuid(), ServiceId = dto.ServiceId, ProjectId = serviceProject.Id, TenantId = tenantId, }); } else if (!dto.IsActive && existingMapping != null) { removedMappings.Add(existingMapping); } } } // Apply added and removed mappings if (newMappings.Any()) _context.ServiceProjectServiceMapping.AddRange(newMappings); if (removedMappings.Any()) _context.ServiceProjectServiceMapping.RemoveRange(removedMappings); // Persist all changes within a single transaction flow await _context.SaveChangesAsync(); // Reload updated project and related entities for comprehensive response var serviceProjectTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceProjects .Include(sp => sp.Client) .Include(sp => sp.Status) .Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole) .Where(sp => sp.Id == id && sp.TenantId == tenantId && sp.IsActive) .FirstOrDefaultAsync(); }); var servicesTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceProjectServiceMapping .Include(sps => sps.Service) .Where(sps => sps.ProjectId == serviceProject.Id && sps.Service != null && sps.TenantId == tenantId) .Select(sps => sps.Service!) .ToListAsync(); }); // 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"); await Task.WhenAll(serviceProjectTask, servicesTask, updateLogTask); serviceProject = serviceProjectTask.Result; services = servicesTask.Result; var projectVm = _mapper.Map(serviceProject); projectVm.Services = _mapper.Map>(services); _logger.LogInfo("Service project {ProjectId} updated successfully by employee {EmployeeId}", id, loggedInEmployee.Id); return ApiResponse.SuccessResponse(projectVm, "Service project updated successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error updating service project {ProjectId} for tenant {TenantId} by employee {EmployeeId}", id, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while updating the service project. Please try again later.", 500); } } /// /// Activates or deactivates a service project based on the specified 'isActive' flag, scoped by tenant. /// /// The unique identifier of the service project to update. /// True to activate, false to deactivate the service project. /// The employee performing the operation (for audit/logging). /// Tenant identifier to ensure multi-tenant data isolation. /// ApiResponse indicating success or detail of failure. public async Task> DeActivateServiceProjectAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("DeActivateServiceProjectAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } try { // Load the service project scoped by tenant and ID var serviceProject = await _context.ServiceProjects .FirstOrDefaultAsync(sp => sp.Id == id && sp.TenantId == tenantId); if (serviceProject == null) { _logger.LogWarning("Attempt to {(Action)} non-existent service project {ProjectId} by employee {EmployeeId} in tenant {TenantId}", isActive ? "activate" : "deactivate", id, loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse("Project Not Found", $"No project found with ID {id}.", 404); } // Create BSON snapshot of existing entity for audit logging (MongoDB) using var scope = _serviceScopeFactory.CreateScope(); var updateLogHelper = scope.ServiceProvider.GetRequiredService(); BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(serviceProject); // Update active status as requested by the client serviceProject.IsActive = isActive; await _context.SaveChangesAsync(); // Push update log asynchronously to MongoDB for audit await updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = existingEntityBson, UpdatedAt = DateTime.UtcNow }, "JobTicketModificationLog"); _logger.LogInfo("Service project {ProjectId} {(Action)}d successfully by employee {EmployeeId} in tenant {TenantId}", id, isActive ? "activate" : "deactivate", loggedInEmployee.Id, tenantId); return ApiResponse.SuccessResponse(new { }, $"Project {(isActive ? "activated" : "deactivated")} successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "Error occurred while {(Action)} service project {ProjectId} by employee {EmployeeId} in tenant {TenantId}", isActive ? "activating" : "deactivating", id, loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse("Internal Server Error", "An internal error occurred, please try again later.", 500); } } #endregion #region =================================================================== Service Project Allocation Functions =================================================================== /// /// Retrieves a list of service project allocations filtered by project ID or employee ID and active status within the tenant context. /// /// Optional project ID filter. /// Optional employee ID filter. /// Filter for active/inactive allocations. /// Employee making the request (for audit/logging). /// Tenant identifier for multi-tenant data isolation. /// ApiResponse with the list of matching service project allocations or error details. public async Task> GetServiceProjectAllocationListAsync(Guid? projectId, Guid? employeeId, bool isActive, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("GetServiceProjectAllocationListAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } if (!projectId.HasValue && !employeeId.HasValue) { _logger.LogInfo("GetServiceProjectAllocationListAsync missing required filters by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Bad Request", "Provide at least one of (ProjectId or EmployeeId).", 400); } try { _logger.LogInfo("Fetching service project allocations for tenant {TenantId} by employee {EmployeeId} " + "with filters - ProjectId: {ProjectId}, EmployeeId: {EmployeeId}, IsActive: {IsActive}", tenantId, loggedInEmployee.Id, projectId ?? Guid.Empty, employeeId ?? Guid.Empty, isActive); // Base query with includes for related navigation properties var allocationQuery = _context.ServiceProjectAllocations .Include(spa => spa.Project) .Include(spa => spa.TeamRole) .Include(spa => spa.Employee).ThenInclude(e => e!.JobRole) .Include(spa => spa.AssignedBy).ThenInclude(e => e!.JobRole) .Include(spa => spa.ReAssignedBy).ThenInclude(e => e!.JobRole) .Where(spa => spa.IsActive == isActive && spa.TenantId == tenantId); // Apply filtering by either project or employee (mutually exclusive) if (projectId.HasValue) { allocationQuery = allocationQuery.Where(spa => spa.ProjectId == projectId.Value); } else if (employeeId.HasValue) { allocationQuery = allocationQuery.Where(spa => spa.EmployeeId == employeeId.Value); } // Execute query and sort results by most recent assignment var allocations = await allocationQuery .OrderByDescending(spa => spa.AssignedAt) .ToListAsync(); var response = _mapper.Map>(allocations); _logger.LogInfo("{Count} service project allocations fetched successfully for tenant {TenantId}", response.Count, tenantId); return ApiResponse.SuccessResponse(response, "Service project allocations fetched successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "Error fetching service project allocations for tenant {TenantId} by employee {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Internal Server Error", "Failed to fetch service project allocations. Please try again later.", 500); } } /// /// Manages service project allocations by adding new active allocations and removing inactive ones. /// Validates projects, employees, and team roles exist before applying changes. /// /// List of allocation DTOs specifying project, employee, team role, and active status. /// Employee performing the allocation management (for audit). /// Tenant identifier for multi-tenant data isolation. /// ApiResponse containing the updated list of active allocations or error details. public async Task> ManageServiceProjectAllocationAsync(List model, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("ManageServiceProjectAllocationAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } if (model == null || !model.Any()) { _logger.LogInfo("Empty allocation model provided by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Bad Request", "No allocation data provided.", 400); } try { // Extract distinct IDs from input for efficient bulk queries var projectIds = model.Select(spa => spa.ProjectId).Distinct().ToList(); var employeeIds = model.Select(spa => spa.EmployeeId).Distinct().ToList(); var teamRoleIds = model.Select(spa => spa.TeamRoleId).Distinct().ToList(); // Load required reference data concurrently using separate DbContexts var projectTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceProjects.AsNoTracking().Where(sp => projectIds.Contains(sp.Id) && sp.TenantId == tenantId && sp.IsActive).ToListAsync(); }); var employeeTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Employees.AsNoTracking().Where(e => employeeIds.Contains(e.Id) && e.IsActive).ToListAsync(); }); var teamRoleTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.TeamRoleMasters.AsNoTracking().Where(trm => teamRoleIds.Contains(trm.Id)).ToListAsync(); }); var allocationTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceProjectAllocations.AsNoTracking().Where(spa => projectIds.Contains(spa.ProjectId) && spa.TenantId == tenantId && spa.IsActive).ToListAsync(); }); await Task.WhenAll(projectTask, employeeTask, teamRoleTask, allocationTask); var projects = projectTask.Result; var employees = employeeTask.Result; var teamRoles = teamRoleTask.Result; var allocations = allocationTask.Result; var newAllocations = new List(); var allocationsToRemove = new List(); // Process each input allocation DTO foreach (var dto in model) { // Validate referenced entities exist var project = projects.FirstOrDefault(sp => sp.Id == dto.ProjectId); var employee = employees.FirstOrDefault(e => e.Id == dto.EmployeeId); var teamRole = teamRoles.FirstOrDefault(tr => tr.Id == dto.TeamRoleId); if (project == null || employee == null || teamRole == null) { _logger.LogWarning("Skipping allocation with invalid references: ProjectId={ProjectId}, EmployeeId={EmployeeId}, TeamRoleId={TeamRoleId}", dto.ProjectId, dto.EmployeeId, dto.TeamRoleId); continue; } var existingAllocation = allocations.FirstOrDefault(spa => spa.ProjectId == dto.ProjectId && spa.EmployeeId == dto.EmployeeId && spa.TeamRoleId == dto.TeamRoleId); if (dto.IsActive && existingAllocation == null) { newAllocations.Add(new ServiceProjectAllocation { Id = Guid.NewGuid(), ProjectId = dto.ProjectId, EmployeeId = dto.EmployeeId, TeamRoleId = dto.TeamRoleId, AssignedAt = DateTime.UtcNow, AssignedById = loggedInEmployee.Id, IsActive = true, TenantId = tenantId }); } else if (!dto.IsActive && existingAllocation != null) { existingAllocation.IsActive = false; existingAllocation.ReAssignedAt = DateTime.UtcNow; existingAllocation.ReAssignedById = loggedInEmployee.Id; allocationsToRemove.Add(existingAllocation); } } // Batch changes for efficiency if (newAllocations.Any()) _context.ServiceProjectAllocations.AddRange(newAllocations); if (allocationsToRemove.Any()) _context.ServiceProjectAllocations.UpdateRange(allocationsToRemove); await _context.SaveChangesAsync(); // Reload current active allocations for response with relevant navigation properties var updatedAllocations = await _context.ServiceProjectAllocations .Include(spa => spa.Project) .Include(spa => spa.TeamRole) .Include(spa => spa.Employee).ThenInclude(e => e!.JobRole) .Include(spa => spa.AssignedBy).ThenInclude(e => e!.JobRole) .Where(spa => projectIds.Contains(spa.ProjectId) && employeeIds.Contains(spa.EmployeeId) && teamRoleIds.Contains(spa.TeamRoleId) && spa.IsActive && spa.TenantId == tenantId) .ToListAsync(); var response = _mapper.Map>(updatedAllocations); _logger.LogInfo("Service project allocations managed successfully by employee {EmployeeId} for tenant {TenantId}. Added {AddedCount}, Removed {RemovedCount}", loggedInEmployee.Id, tenantId, newAllocations.Count, allocationsToRemove.Count); return ApiResponse.SuccessResponse(response, "Service project allocations managed successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "Error managing service project allocations by employee {EmployeeId} in tenant {TenantId}", loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while managing service project allocations. Please try again later.", 500); } } #endregion #region =================================================================== Job Tickets Functions =================================================================== /// /// Retrieves a paginated, filtered list of job tickets for a tenant, including related project, status, assignees, and tags. /// /// Optional project filter. /// Page index (1-based). /// Page size. /// Active filter. /// Tenant context. /// Employee requesting data. /// Paged list of JobTicketVM plus metadata, or error response. public async Task> GetJobTicketsListAsync(Guid? projectId, int pageNumber, int pageSize, bool isActive, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("TenantId missing for job ticket fetch by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse( "Access Denied", "Missing or invalid tenant context.", 403); } if (pageNumber < 1 || pageSize < 1) { _logger.LogInfo("Invalid paging parameters for job ticket fetch. PageNumber: {PageNumber}, PageSize: {PageSize}", pageNumber, pageSize); return ApiResponse.ErrorResponse( "Bad Request", "Page number and size must be greater than zero.", 400); } try { // Build filtered query with necessary includes for eager loading var jobTicketQuery = _context.JobTickets .Include(jt => jt.Status) .Include(jt => jt.Project) .Include(jt => jt.CreatedBy).ThenInclude(e => e!.JobRole) .AsNoTracking() .Where(jt => jt.TenantId == tenantId && jt.IsActive == isActive && jt.Project != null && jt.Status != null && jt.CreatedBy != null && jt.CreatedBy.JobRole != null); // Optionally filter by project if (projectId.HasValue) { var projectExists = await _context.ServiceProjects .AnyAsync(sp => sp.Id == projectId && sp.TenantId == tenantId && sp.IsActive); if (!projectExists) { _logger.LogWarning("Requested service project not found. ProjectId: {ProjectId}, TenantId: {TenantId}", projectId, tenantId); return ApiResponse.ErrorResponse("Service project not found", "Service project not found for this tenant.", 404); } jobTicketQuery = jobTicketQuery.Where(jt => jt.ProjectId == projectId.Value); } // Total results and paging var totalEntities = await jobTicketQuery.CountAsync(); var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize); // Fetch filtered/paged tickets var jobTickets = await jobTicketQuery .OrderByDescending(e => e.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); var jobTicketIds = jobTickets.Select(jt => jt.Id).ToList(); // Fetch assignee and tag mappings concurrently using separate DbContexts for parallel IO var assigneeTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.JobEmployeeMappings .Include(jem => jem.Assignee).ThenInclude(e => e!.JobRole) .Where(jem => jobTicketIds.Contains(jem.JobTicketId) && jem.Assignee != null && jem.Assignee.JobRole != null && jem.TenantId == tenantId) .ToListAsync(); }); var tagTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.JobTagMappings .Include(jtm => jtm.JobTag) .Where(jtm => jobTicketIds.Contains(jtm.JobTicketId) && jtm.JobTag != null && jtm.TenantId == tenantId) .ToListAsync(); }); await Task.WhenAll(assigneeTask, tagTask); var assigneeMappings = assigneeTask.Result; var tagMappings = tagTask.Result; // Map tickets to view models and inject assignees/tags per ticket var jobTicketVMs = jobTickets.Select(jt => { var vm = _mapper.Map(jt); vm.Assignees = assigneeMappings .Where(jem => jem.JobTicketId == jt.Id) .Select(jem => _mapper.Map(jem.Assignee)) .ToList(); vm.Tags = tagMappings .Where(jtm => jtm.JobTicketId == jt.Id) .Select(jtm => _mapper.Map(jtm.JobTag)) .ToList(); return vm; }).ToList(); var response = new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntities = totalEntities, Data = jobTicketVMs, }; _logger.LogInfo("Job tickets fetched: {Count} tickets for tenant {TenantId}, page {PageNumber}", jobTicketVMs.Count, tenantId, pageNumber); return ApiResponse.SuccessResponse(response, $"{jobTicketVMs.Count} job records fetched successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "Exception in fetching job tickets list for tenant {TenantId} by employee {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Internal Server Error", "Unable to fetch job tickets list. Please try again later.", 500); } } /// /// Retrieves detailed information for a specific job ticket, including assignees, tags, comments, and update logs. /// /// Job ticket identifier. /// Employee making the request (for logging/audit). /// Tenant identifier for multi-tenant scoping. /// ApiResponse containing detailed job ticket view or error. public async Task> GetJobTicketDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("TenantId is empty for job ticket details request by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } try { _logger.LogInfo("Fetching details for job ticket {JobTicketId} by employee {EmployeeId} in tenant {TenantId}", id, loggedInEmployee.Id, tenantId); // Load main job ticket with navigation properties var jobTicket = await _context.JobTickets .Include(jt => jt.Status) .Include(jt => jt.Project) .Include(jt => jt.CreatedBy).ThenInclude(e => e!.JobRole) .AsNoTracking() .FirstOrDefaultAsync(jt => jt.Id == id && jt.TenantId == tenantId && jt.IsActive && jt.Project != null && jt.Status != null && jt.CreatedBy != null && jt.CreatedBy.JobRole != null); if (jobTicket == null) { _logger.LogWarning("Job ticket not found or inactive. JobTicketId: {JobTicketId}, TenantId: {TenantId}", id, tenantId); return ApiResponse.ErrorResponse("Job not found", "Job ticket not found or inactive.", 404); } // Load all job statuses for status mappings in logs var statusList = await _context.JobStatus.ToListAsync(); // Use parallel queries with separate DbContexts to optimize IO var assigneeTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.JobEmployeeMappings .Include(jem => jem.Assignee).ThenInclude(e => e!.JobRole) .Where(jem => jem.JobTicketId == id && jem.Assignee != null && jem.Assignee.JobRole != null && jem.TenantId == tenantId) .Select(jem => jem.Assignee!) .ToListAsync(); }); var tagTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.JobTagMappings .Include(jtm => jtm.JobTag) .Where(jtm => jtm.JobTicketId == id && jtm.JobTag != null && jtm.TenantId == tenantId) .Select(jtm => jtm.JobTag!) .ToListAsync(); }); var updateLogTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.StatusUpdateLogs .Include(ul => ul.UpdatedBy).ThenInclude(e => e!.JobRole) .Where(ul => ul.EntityId == id && ul.TenantId == tenantId) .ToListAsync(); }); await Task.WhenAll(assigneeTask, tagTask, updateLogTask); var assignees = assigneeTask.Result; var isAssigned = assignees.Any(e => e.Id == loggedInEmployee.Id); // Map update logs with status descriptions var jobUpdateLogVMs = updateLogTask.Result.Select(ul => { var status = statusList.FirstOrDefault(js => js.Id == ul.StatusId); var nextStatus = statusList.FirstOrDefault(js => js.Id == ul.NextStatusId); return new JobUpdateLogVM { Id = ul.Id, Status = status, NextStatus = nextStatus, Comment = ul.Comment, UpdatedBy = _mapper.Map(ul.UpdatedBy) }; }).ToList(); // Map assignees, and tags to their respective viewmodels var assigneeVMs = _mapper.Map>(assignees); var tagVMs = _mapper.Map>(tagTask.Result); // Map main job ticket DTO and attach related data var response = _mapper.Map(jobTicket); response.Assignees = assigneeVMs; response.Tags = tagVMs; response.UpdateLogs = jobUpdateLogVMs; if (isAssigned) { // Fetch the most recent attendance record for the logged-in employee for the specified job var jobAttendance = await _context.JobAttendance .AsNoTracking() .Where(ja => ja.JobTcketId == jobTicket.Id && ja.EmployeeId == loggedInEmployee.Id && ja.TenantId == tenantId) .OrderByDescending(ja => ja.TaggedInTime) .FirstOrDefaultAsync(); // If no attendance record exists or last record is tagged out or for a different day, prepare a default response with next action TAG_IN if (jobAttendance == null || (jobAttendance.TaggedOutTime.HasValue && jobAttendance.TaggedInTime.Date != DateTime.UtcNow.Date)) { response.NextTaggingAction = TAGGING_MARK_TYPE.TAG_IN; _logger.LogInfo("No current active attendance found for EmployeeId: {EmployeeId}. Prompting to TAG_IN.", loggedInEmployee.Id); } else { // Active attendance exists response.TaggingAction = jobAttendance.Action; response.NextTaggingAction = jobAttendance.Action == TAGGING_MARK_TYPE.TAG_IN ? TAGGING_MARK_TYPE.TAG_OUT : TAGGING_MARK_TYPE.TAG_IN; _logger.LogInfo("Latest attendance fetched for EmployeeId: {EmployeeId} on JobTicketId: {JobTicketId}", loggedInEmployee.Id, jobTicket.Id); } } _logger.LogInfo("Job ticket details assembled successfully for JobTicketId: {JobTicketId}", id); return ApiResponse.SuccessResponse(response, "Job details fetched successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch job ticket details for JobTicketId: {JobTicketId} in tenant {TenantId}", id, tenantId); return ApiResponse.ErrorResponse("Internal Server Error", "Failed to retrieve job details. Please try again later.", 500); } } /// /// Retrieves all job tags associated with the tenant, ordered alphabetically by name. /// /// Employee making the request (for audit/logging). /// Tenant identifier for multi-tenant data scoping. /// ApiResponse containing the list of job tag view models or error details. public async Task> GetJobTagListAsync(Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("Attempt to fetch job tags with missing tenant context by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } try { _logger.LogInfo("Fetching job tags for tenant {TenantId} requested by employee {EmployeeId}", tenantId, loggedInEmployee.Id); // Query job tags via JobTagMappings ensuring tenant isolation var tags = await _context.JobTagMappings .AsNoTracking() .Include(jtm => jtm.JobTag) .Where(jtm => jtm.JobTag != null && jtm.TenantId == tenantId) .Select(jtm => jtm.JobTag!) .Distinct() // Avoid duplicates if any tag maps multiple times .OrderBy(jt => jt.Name) .ToListAsync(); var response = _mapper.Map>(tags); _logger.LogInfo("{Count} job tags fetched successfully for tenant {TenantId}", response.Count, tenantId); return ApiResponse.SuccessResponse(response, $"{response.Count} job tag record(s) fetched successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch job tags for tenant {TenantId} requested by employee {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Internal Server Error", "Failed to retrieve job tags. Please try again later.", 500); } } /// /// Creates a new job ticket with optional assignees and tags within a transactional scope. /// /// Data transfer object containing job ticket details. /// Employee initiating the creation. /// Tenant identifier for multi-tenant context. /// ApiResponse containing the created job ticket view or error details. public async Task> CreateJobTicketAsync(CreateJobTicketDto model, Employee loggedInEmployee, Guid tenantId) { await using var transaction = await _context.Database.BeginTransactionAsync(); try { _logger.LogInfo("Starting job ticket creation for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}", model.ProjectId, loggedInEmployee.Id, tenantId); // Load project and relevant statuses in parallel with separate DbContext instances for concurrency efficiency var serviceProjectTask = 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.Where(js => js.Id == NewStatus || js.Id == AssignedStatus).ToListAsync(); }); await Task.WhenAll(serviceProjectTask, statusTask); var serviceProject = serviceProjectTask.Result; var statusList = statusTask.Result; if (serviceProject == null) { _logger.LogWarning("Service project with ID {ProjectId} not found or inactive in tenant {TenantId}", model.ProjectId, tenantId); return ApiResponse.ErrorResponse("Service project not found", "Service project not found or inactive", 404); } var hasAssignees = model.Assignees?.Any(a => a.IsActive) ?? false; string uIDPrefix = $"JT/{DateTime.Now:MMyy}"; // Generate unique UID postfix based on existing requests for the current prefix var lastPR = await _context.JobTickets.Where(pr => pr.UIDPrefix == uIDPrefix) .OrderByDescending(pr => pr.UIDPostfix) .FirstOrDefaultAsync(); int uIDPostfix = lastPR == null ? 1 : (lastPR.UIDPostfix + 1); // Map DTO to entity var jobTicket = _mapper.Map(model); jobTicket.Id = Guid.NewGuid(); jobTicket.StatusId = hasAssignees ? AssignedStatus : NewStatus; jobTicket.UIDPrefix = uIDPrefix; jobTicket.UIDPostfix = uIDPostfix; jobTicket.CreatedAt = DateTime.UtcNow; jobTicket.CreatedById = loggedInEmployee.Id; jobTicket.TenantId = tenantId; _context.JobTickets.Add(jobTicket); await _context.SaveChangesAsync(); // Handle assignees if any List assigneeVMs = new(); if (hasAssignees) { var activeAssigneeIds = model.Assignees!.Where(a => a.IsActive).Select(a => a.EmployeeId).ToList(); var assignees = await _context.Employees .Include(e => e.JobRole) .Where(e => activeAssigneeIds.Contains(e.Id) && e.IsActive) .ToListAsync(); var jobEmployeeMappings = assignees.Select(e => new JobEmployeeMapping { AssigneeId = e.Id, JobTicketId = jobTicket.Id, TenantId = tenantId }).ToList(); _context.JobEmployeeMappings.AddRange(jobEmployeeMappings); assigneeVMs = _mapper.Map>(assignees); } // Handle tags if any var jobTags = new List(); if (model.Tags?.Any(t => t.IsActive) ?? false) { var activeTagNames = model.Tags.Where(t => t.IsActive).Select(t => t.Name).Distinct().ToList(); var existingTags = await _context.JobTags.Where(jt => activeTagNames.Contains(jt.Name) && jt.TenantId == tenantId).ToListAsync(); var newTags = new List(); var tagMappings = new List(); foreach (var tagDto in model.Tags.Where(t => t.IsActive).DistinctBy(t => t.Name)) { var tag = existingTags.FirstOrDefault(jt => jt.Name == tagDto.Name); if (tag == null) { tag = new JobTag { Id = Guid.NewGuid(), Name = tagDto.Name, TenantId = tenantId }; newTags.Add(tag); } tagMappings.Add(new JobTagMapping { Id = Guid.NewGuid(), JobTagId = tag.Id, JobTicketId = jobTicket.Id, TenantId = tenantId }); } if (newTags.Any()) _context.JobTags.AddRange(newTags); _context.JobTagMappings.AddRange(tagMappings); jobTags.AddRange(existingTags); jobTags.AddRange(newTags); } // Create job update logs for status change var updateLogs = new List(); const string creationMessage = "A new job has been successfully created and added to the system. It is now ready for assignment and further processing."; updateLogs.Add(new StatusUpdateLog { EntityId = jobTicket.Id, NextStatusId = NewStatus, Comment = creationMessage, UpdatedAt = DateTime.UtcNow, UpdatedById = loggedInEmployee.Id, TenantId = tenantId }); if (hasAssignees) { const string assignmentMessage = "The designated assignee(s) have been successfully assigned to the job and are now responsible for managing and completing the associated tasks."; updateLogs.Add(new StatusUpdateLog { EntityId = jobTicket.Id, StatusId = NewStatus, NextStatusId = AssignedStatus, Comment = assignmentMessage, UpdatedAt = DateTime.UtcNow.AddTicks(10), // Small offset to preserve time order UpdatedById = loggedInEmployee.Id, TenantId = tenantId }); } _context.StatusUpdateLogs.AddRange(updateLogs); await _context.SaveChangesAsync(); await transaction.CommitAsync(); // Prepare response VM var currentStatus = statusList.FirstOrDefault(js => js.Id == jobTicket.StatusId); var vm = _mapper.Map(jobTicket); vm.Status = currentStatus; vm.Project = _mapper.Map(serviceProject); vm.Assignees = assigneeVMs; vm.CreatedBy = _mapper.Map(loggedInEmployee); vm.Tags = _mapper.Map>(jobTags.Distinct().ToList()); _logger.LogInfo("Job ticket {JobTicketId} successfully created with status {StatusId} by employee {EmployeeId} in tenant {TenantId}", jobTicket.Id, jobTicket.StatusId, loggedInEmployee.Id, tenantId); return ApiResponse.SuccessResponse(vm, "Job created successfully", 201); } catch (DbUpdateException dbEx) { await transaction.RollbackAsync(); _logger.LogError(dbEx, "Database error while creating 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 creating 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); } } /// /// Changes the status of a specified job ticket, recording the change in a status update log. /// Ensures team-role-aware status transitions if applicable. /// /// DTO containing target status ID, job ticket ID, and optional comment. /// Employee performing the status change (for audit and permissions). /// Tenant context for multi-tenancy. /// ApiResponse with updated job ticket or error info. public async Task> ChangeJobsStatusAsync(ChangeJobStatusDto model, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("ChangeJobsStatusAsync: Invalid (empty) tenantId for employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } if (model == null || model.JobTicketId == Guid.Empty || model.StatusId == Guid.Empty) { _logger.LogInfo("ChangeJobsStatusAsync: Invalid parameters submitted by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Bad Request", "Job or status ID is missing.", 400); } try { _logger.LogInfo("Attempting to change status for job {JobTicketId} to {NewStatusId} by employee {EmployeeId}", model.JobTicketId, model.StatusId, loggedInEmployee.Id); // Load the job ticket and key navigation properties var jobTicket = await _context.JobTickets .Include(jt => jt.Project) .Include(jt => jt.CreatedBy).ThenInclude(e => e!.JobRole) .FirstOrDefaultAsync(jt => jt.Id == model.JobTicketId && jt.TenantId == tenantId); if (jobTicket == null) { _logger.LogWarning("Job ticket {JobTicketId} not found for status change in tenant {TenantId}", model.JobTicketId, tenantId); return ApiResponse.ErrorResponse("Job Not Found", "Job ticket not found.", 404); } var jobStatusMapping = await GetJobStatusMappingAsync(jobTicket.StatusId, model.StatusId, jobTicket.ProjectId, loggedInEmployee.Id, tenantId); if (jobStatusMapping == null) { _logger.LogWarning("Invalid status transition requested: current={CurrentStatusId}, desired={DesiredStatusId}, tenant={TenantId}", jobTicket.StatusId, model.StatusId, tenantId); return ApiResponse.ErrorResponse("Invalid Status", "Selected status transition is not allowed.", 400); } // Apply the new status and metadata jobTicket.StatusId = model.StatusId; jobTicket.UpdatedAt = DateTime.UtcNow; jobTicket.UpdatedById = loggedInEmployee.Id; // Write status change log var updateLog = new StatusUpdateLog { Id = Guid.NewGuid(), EntityId = jobTicket.Id, StatusId = jobStatusMapping.StatusId, NextStatusId = jobStatusMapping.NextStatusId, Comment = model.Comment, UpdatedAt = DateTime.UtcNow, UpdatedById = loggedInEmployee.Id, TenantId = tenantId }; _context.StatusUpdateLogs.Add(updateLog); await _context.SaveChangesAsync(); // Prepare response VM var responseVm = _mapper.Map(jobTicket); responseVm.Status = jobStatusMapping.NextStatus; _logger.LogInfo("Job {JobTicketId} status changed to {NewStatusId} by employee {EmployeeId}", jobTicket.Id, model.StatusId, loggedInEmployee.Id); return ApiResponse.SuccessResponse(responseVm, "Job status changed successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "Exception during ChangeJobsStatusAsync for job {JobTicketId} by employee {EmployeeId} in tenant {TenantId}", model.JobTicketId, loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse("Internal Server Error", "Failed to change job status. Please try again later.", 500); } } /// /// 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); } // Begin database transaction for atomicity await using var transaction = await _context.Database.BeginTransactionAsync(); 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); } // 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) { await transaction.RollbackAsync(); _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 =================================================================== /// /// Retrieves a paginated list of comments with attachments for a specified job ticket within a tenant context. /// /// Optional job ticket ID to filter comments. /// Page number (1-based) for pagination. /// Page size for pagination. /// Employee making the request (for authorization and logging). /// Tenant context ID for multi-tenancy. /// ApiResponse with paged comments, attachments, and metadata, or error details. public async Task> GetCommentListByJobTicketAsync(Guid? jobTicketId, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { _logger.LogWarning("TenantId missing in comment list request by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } if (pageNumber < 1 || pageSize < 1) { _logger.LogInfo("Invalid pagination parameters in comment list request. PageNumber: {PageNumber}, PageSize: {PageSize}", pageNumber, pageSize); return ApiResponse.ErrorResponse("Bad Request", "Page number and size must be greater than zero.", 400); } try { _logger.LogInfo("Fetching comment list for jobTicketId {JobTicketId} by employee {EmployeeId} in tenant {TenantId}", jobTicketId ?? Guid.Empty, loggedInEmployee.Id, tenantId); var commentQuery = _context.JobComments .Include(jc => jc.JobTicket).ThenInclude(jt => jt!.Status) .Include(jc => jc.CreatedBy).ThenInclude(e => e!.JobRole) .Include(jc => jc.UpdatedBy).ThenInclude(e => e!.JobRole) .Where(jc => jc.TenantId == tenantId && jc.JobTicket != null && jc.CreatedBy != null && jc.CreatedBy.JobRole != null); // Filter by jobTicketId if provided after verifying existence if (jobTicketId.HasValue) { var jobTicketExists = await _context.JobTickets.AnyAsync(jt => jt.Id == jobTicketId && jt.TenantId == tenantId); if (!jobTicketExists) { _logger.LogWarning("Job ticket {JobTicketId} not found in tenant {TenantId} for comment listing", jobTicketId, tenantId); return ApiResponse.ErrorResponse("Job not found", "Job ticket not found.", 404); } commentQuery = commentQuery.Where(jc => jc.JobTicketId == jobTicketId.Value); } // Calculate total count for pagination var totalEntities = await commentQuery.CountAsync(); var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize); // Fetch paged comments ordered by creation date descending var comments = await commentQuery .OrderByDescending(jc => jc.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); var commentIds = comments.Select(jc => jc.Id).ToList(); // Fetch attachments for current page comments var attachments = await _context.JobAttachments .Include(ja => ja.Document) .Where(ja => ja.JobCommentId.HasValue && ja.Document != null && commentIds.Contains(ja.JobCommentId.Value) && ja.TenantId == tenantId) .ToListAsync(); // Map comments and attach corresponding documents with pre-signed URLs for access var commentVMs = comments.Select(jc => { var relatedDocuments = attachments .Where(ja => ja.JobCommentId == jc.Id) .Select(ja => ja.Document!) .ToList(); var mappedComment = _mapper.Map(jc); if (relatedDocuments.Any()) { mappedComment.Attachments = relatedDocuments.Select(doc => { var docVM = _mapper.Map(doc); docVM.PreSignedUrl = _s3Service.GeneratePreSignedUrl(doc.S3Key); docVM.ThumbPreSignedUrl = string.IsNullOrWhiteSpace(doc.ThumbS3Key) ? _s3Service.GeneratePreSignedUrl(doc.S3Key) : _s3Service.GeneratePreSignedUrl(doc.ThumbS3Key); return docVM; }).ToList(); } return mappedComment; }).ToList(); var response = new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntities = totalEntities, Data = commentVMs }; _logger.LogInfo("{Count} comments fetched successfully for jobTicketId {JobTicketId} by employee {EmployeeId}", commentVMs.Count, jobTicketId ?? Guid.Empty, loggedInEmployee.Id); return ApiResponse.SuccessResponse(response, $"{commentVMs.Count} record(s) fetched successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "Error fetching comments for jobTicketId {JobTicketId} by employee {EmployeeId} in tenant {TenantId}", jobTicketId ?? Guid.Empty, loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse("Internal Server Error", "Failed to fetch comments. Please try again later.", 500); } } /// /// Adds a comment with optional attachments to a job ticket within the specified tenant context. /// /// DTO containing comment content, job ticket ID, and attachments. /// Employee making the comment (for auditing). /// Tenant context for data isolation. /// ApiResponse containing created comment details or relevant error information. public async Task> AddCommentToJobTicketAsync(JobCommentDto model, Employee loggedInEmployee, Guid tenantId) { // Validate tenant context if (tenantId == Guid.Empty) { _logger.LogWarning("Add comment attempt with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } // Validate input DTO if (model == null || model.JobTicketId == Guid.Empty || string.IsNullOrWhiteSpace(model.Comment)) { _logger.LogInfo("Invalid comment model provided by employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Bad Request", "Comment data is incomplete or invalid.", 400); } try { _logger.LogInfo("Attempting to add comment to job ticket {JobTicketId} by employee {EmployeeId}", model.JobTicketId, loggedInEmployee.Id); // Verify the job ticket's existence and load minimal required info var jobTicket = await _context.JobTickets .Include(jt => jt.Status) .AsNoTracking() .FirstOrDefaultAsync(jt => jt.Id == model.JobTicketId && jt.TenantId == tenantId); if (jobTicket == null) { _logger.LogWarning("Job ticket {JobTicketId} not found or inaccessible in tenant {TenantId}", model.JobTicketId, tenantId); return ApiResponse.ErrorResponse("Job Not Found", "Job ticket not found or inaccessible.", 404); } // Create new comment entity var comment = new JobComment { Id = Guid.NewGuid(), JobTicketId = jobTicket.Id, Comment = model.Comment.Trim(), IsActive = true, CreatedAt = DateTime.UtcNow, CreatedById = loggedInEmployee.Id, TenantId = tenantId }; _context.JobComments.Add(comment); // Handle attachments if provided if (model.Attachments?.Any() ?? false) { var batchId = Guid.NewGuid(); var documents = new List(); var attachments = new List(); foreach (var attachment in model.Attachments) { string base64 = attachment.Base64Data?.Split(',').LastOrDefault() ?? ""; if (string.IsNullOrWhiteSpace(base64)) { _logger.LogWarning("Attachment missing base64 data in comment for job ticket {JobTicketId} by employee {EmployeeId}", jobTicket.Id, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Bad Request", "Attachment image data is missing or invalid.", 400); } // Determine content type and generate storage keys var fileType = _s3Service.GetContentTypeFromBase64(base64); var fileName = _s3Service.GenerateFileName(fileType, tenantId, "job_comment"); var objectKey = $"tenant-{tenantId}/ServiceProject/{jobTicket.ProjectId}/Job/{jobTicket.Id}/Comment/{comment.Id}/{fileName}"; // Upload file asynchronously to S3 await _s3Service.UploadFileAsync(base64, fileType, objectKey); // Create document record for uploaded file var document = new Document { Id = Guid.NewGuid(), BatchId = batchId, FileName = attachment.FileName ?? fileName, ContentType = fileType, S3Key = objectKey, FileSize = attachment.FileSize, UploadedAt = DateTime.UtcNow, UploadedById = loggedInEmployee.Id, TenantId = tenantId }; documents.Add(document); // Link document as attachment to the comment attachments.Add(new JobAttachment { Id = Guid.NewGuid(), DocumentId = document.Id, StatusId = jobTicket.StatusId, JobCommentId = comment.Id, TenantId = tenantId }); } _context.Documents.AddRange(documents); _context.JobAttachments.AddRange(attachments); } // Persist all inserts in database await _context.SaveChangesAsync(); // Prepare response with mapped comment, creator info, and basic job ticket info var response = _mapper.Map(comment); response.CreatedBy = _mapper.Map(loggedInEmployee); response.JobTicket = _mapper.Map(jobTicket); _logger.LogInfo("Successfully added comment {CommentId} to job ticket {JobTicketId} by employee {EmployeeId}", comment.Id, jobTicket.Id, loggedInEmployee.Id); return ApiResponse.SuccessResponse(response, "Comment added to job ticket successfully.", 201); } catch (Exception ex) { _logger.LogError(ex, "Error adding comment to job ticket {JobTicketId} by employee {EmployeeId} in tenant {TenantId}", model.JobTicketId, loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse("Internal Server Error", "Failed to add comment. Please try again later.", 500); } } /// /// Updates a job comment, including its content and attachments, with audit and error logging. /// /// ID of the job comment to be updated. /// DTO containing updated comment and attachment details. /// Employee performing the update (for audit/versioning). /// Tenant identifier for multi-tenant isolation. /// ApiResponse containing updated comment details or error information. public async Task> UpdateCommentAsync(Guid id, JobCommentDto model, Employee loggedInEmployee, Guid tenantId) { // Transaction ensures atomic update of comment and attachments. await using var transaction = await _context.Database.BeginTransactionAsync(); try { // Validate ID consistency and input presence if (!model.Id.HasValue || model.Id != id) { _logger.LogWarning("ID mismatch: route ({RouteId}) vs model ({ModelId}) by employee {EmployeeId}", id, model.Id ?? Guid.Empty, loggedInEmployee.Id); return ApiResponse.ErrorResponse("ID mismatch between route and payload", "ID mismatch between route and payload", 400); } // Concurrently fetch existing job comment and related active job ticket for validation var jobCommentTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.JobComments .AsNoTracking() .FirstOrDefaultAsync(jc => jc.Id == id && jc.JobTicketId == model.JobTicketId && jc.TenantId == tenantId); }); var jobTicketTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.JobTickets .AsNoTracking() .FirstOrDefaultAsync(jc => jc.Id == model.JobTicketId && jc.TenantId == tenantId && jc.IsActive); }); await Task.WhenAll(jobCommentTask, jobTicketTask); var jobComment = jobCommentTask.Result; var jobTicket = jobTicketTask.Result; if (jobTicket == null) { _logger.LogWarning("Job ticket {JobTicketId} not found for updating comment {CommentId}", model.JobTicketId, id); return ApiResponse.ErrorResponse("Job not found", "Job not found", 404); } if (jobComment == null) { _logger.LogWarning("Job comment {CommentId} not found for update.", id); return ApiResponse.ErrorResponse("Job Comment not found", "Job Comment not found", 404); } // Audit: BSON snapshot before update (MongoDB) using var scope = _serviceScopeFactory.CreateScope(); var updateLogHelper = scope.ServiceProvider.GetRequiredService(); BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(jobComment); // Update comment core fields and audit _mapper.Map(model, jobComment); jobComment.UpdatedAt = DateTime.UtcNow; jobComment.UpdatedById = loggedInEmployee.Id; _context.JobComments.Update(jobComment); await _context.SaveChangesAsync(); // Attachment: Add new or remove deleted as specified in DTO if (model.Attachments?.Any() == true) { // New attachments var newBillAttachments = model.Attachments.Where(ba => ba.DocumentId == null && ba.IsActive).ToList(); if (newBillAttachments.Any()) { var batchId = Guid.NewGuid(); var documents = new List(); var attachments = new List(); foreach (var attachment in newBillAttachments) { string base64 = attachment.Base64Data?.Split(',').LastOrDefault() ?? ""; if (string.IsNullOrWhiteSpace(base64)) { _logger.LogWarning("Missing base64 data for new attachment in comment {CommentId}", id); return ApiResponse.ErrorResponse("Bad Request", "Attachment image data is missing or invalid.", 400); } // File upload and document creation var fileType = _s3Service.GetContentTypeFromBase64(base64); var fileName = _s3Service.GenerateFileName(fileType, tenantId, "job_comment"); var objectKey = $"tenant-{tenantId}/ServiceProject/{jobTicket.ProjectId}/Job/{jobTicket.Id}/Comment/{jobComment.Id}/{fileName}"; await _s3Service.UploadFileAsync(base64, fileType, objectKey); var document = new Document { Id = Guid.NewGuid(), BatchId = batchId, FileName = attachment.FileName ?? fileName, ContentType = fileType, S3Key = objectKey, FileSize = attachment.FileSize, UploadedAt = DateTime.UtcNow, UploadedById = loggedInEmployee.Id, TenantId = tenantId }; documents.Add(document); attachments.Add(new JobAttachment { Id = Guid.NewGuid(), DocumentId = document.Id, StatusId = jobTicket.StatusId, JobCommentId = id, TenantId = tenantId }); } _context.Documents.AddRange(documents); _context.JobAttachments.AddRange(attachments); try { await _context.SaveChangesAsync(); _logger.LogInfo("{Count} new attachments added to comment {CommentId} by employee {EmployeeId}", newBillAttachments.Count, id, loggedInEmployee.Id); } catch (DbUpdateException dbEx) { await transaction.RollbackAsync(); _logger.LogError(dbEx, "Database error adding new attachments for comment {CommentId}", id); return ApiResponse.ErrorResponse("Database Error", dbEx.Message, 500); } } // Attachments for deletion var deleteBillAttachments = model.Attachments.Where(ba => ba.DocumentId != null && !ba.IsActive).ToList(); if (deleteBillAttachments.Any()) { var documentIds = deleteBillAttachments.Select(d => d.DocumentId!.Value).ToList(); try { await DeleteJobAttachemnts(documentIds); _logger.LogInfo("{Count} attachments deleted for comment {CommentId} by employee {EmployeeId}", deleteBillAttachments.Count, id, loggedInEmployee.Id); } catch (DbUpdateException dbEx) { await transaction.RollbackAsync(); _logger.LogError(dbEx, "Database error deleting attachments during comment update {CommentId}", id); return ApiResponse.ErrorResponse("Database Error", dbEx.Message, 500); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "General error deleting attachments during comment update {CommentId}", id); return ApiResponse.ErrorResponse("Attachment Deletion Error", ex.Message, 500); } } } // Push audit log to MongoDB var updateLogTask = updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = existingEntityBson, UpdatedAt = DateTime.UtcNow }, "JobTicketModificationLog"); // Reload updated comment with related entities for comprehensive response var jobCommentTaskReload = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.JobComments .Include(jc => jc.JobTicket).ThenInclude(jt => jt!.Status) .Include(jc => jc.CreatedBy).ThenInclude(e => e!.JobRole) .Include(jc => jc.UpdatedBy).ThenInclude(e => e!.JobRole) .AsNoTracking() .FirstOrDefaultAsync(jc => jc.Id == id && jc.JobTicketId == model.JobTicketId && jc.TenantId == tenantId); }); var documentReloadTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.JobAttachments .Include(ja => ja.Document) .AsNoTracking() .Where(ja => ja.JobCommentId == id && ja.Document != null && ja.TenantId == tenantId) .Select(ja => ja.Document!) .ToListAsync(); }); await Task.WhenAll(updateLogTask, jobCommentTaskReload, documentReloadTask); var updatedJobComment = jobCommentTaskReload.Result; var updatedDocuments = documentReloadTask.Result; var response = _mapper.Map(updatedJobComment); response.Attachments = updatedDocuments.Select(doc => { var docVM = _mapper.Map(doc); docVM.PreSignedUrl = _s3Service.GeneratePreSignedUrl(doc.S3Key); docVM.ThumbPreSignedUrl = string.IsNullOrWhiteSpace(doc.ThumbS3Key) ? _s3Service.GeneratePreSignedUrl(doc.S3Key) : _s3Service.GeneratePreSignedUrl(doc.ThumbS3Key); return docVM; }).ToList(); await transaction.CommitAsync(); _logger.LogInfo("Comment {CommentId} updated successfully by employee {EmployeeId}", id, loggedInEmployee.Id); return ApiResponse.SuccessResponse(response, "Comment updated successfully", 200); } catch (DbUpdateException dbEx) { await transaction.RollbackAsync(); _logger.LogError(dbEx, "Database error while updating comment {CommentId} by employee {EmployeeId} in tenant {TenantId}", id, loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse("Database Error", "An error occurred while saving data to the database.", 500); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Unhandled exception while updating comment {CommentId} by employee {EmployeeId} in tenant {TenantId}", id, loggedInEmployee.Id, tenantId); return ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500); } } #endregion #region =================================================================== Job Tagging Functions =================================================================== public async Task> GetAttendanceForSelfAsync(Guid jobTicketId, Employee loggedInEmployee, Guid tenantId) { _logger.LogInfo("GetAttendanceForSelfAsync initiated for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, jobTicketId); try { // Validate existence of the Job Ticket with related Status var jobTicket = await _context.JobTickets .AsNoTracking() .Include(jt => jt.Status) .FirstOrDefaultAsync(jt => jt.Id == jobTicketId && jt.TenantId == tenantId && jt.IsActive); if (jobTicket == null) { _logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", jobTicketId, tenantId); return ApiResponse.ErrorResponse("Job not found", "Job is not found", 404); } var jobEmployeeMapping = await _context.JobEmployeeMappings .Where(jem => jem.AssigneeId == loggedInEmployee.Id && jem.JobTicketId == jobTicketId && jem.TenantId == tenantId) .FirstOrDefaultAsync(); // Check if the job-to-employee mapping is null, indicating no assignment if (jobEmployeeMapping == null) { // Log the error with relevant context for diagnostics _logger.LogWarning("Tagging failed: Employee is not assigned to the job. JobId: {JobId}, EmployeeId: {EmployeeId}", jobTicketId, loggedInEmployee.Id); // Return a structured error response with meaningful message and HTTP 400 Bad Request code return ApiResponse.ErrorResponse( "Tagging operation failed because the employee is not assigned to this job.", $"No job-employee mapping found for JobId: {jobTicketId} and EmployeeId: {loggedInEmployee.Id}.", statusCode: 400); } // Fetch the most recent attendance record for the logged-in employee for the specified job var jobAttendance = await _context.JobAttendance .AsNoTracking() .Include(ja => ja.JobTicket).ThenInclude(jt => jt!.Status) .Include(ja => ja.Employee).ThenInclude(e => e!.JobRole) .Where(ja => ja.JobTcketId == jobTicketId && ja.EmployeeId == loggedInEmployee.Id && ja.TenantId == tenantId) .OrderByDescending(ja => ja.TaggedInTime) .FirstOrDefaultAsync(); JobAttendanceVM response; // If no attendance record exists or last record is tagged out or for a different day, prepare a default response with next action TAG_IN if (jobAttendance == null || (jobAttendance.TaggedOutTime.HasValue && jobAttendance.TaggedInTime.Date != DateTime.UtcNow.Date)) { response = new JobAttendanceVM { JobTicket = _mapper.Map(jobTicket), Employee = _mapper.Map(loggedInEmployee), NextAction = TAGGING_MARK_TYPE.TAG_IN }; _logger.LogInfo("No current active attendance found for EmployeeId: {EmployeeId}. Prompting to TAG_IN.", loggedInEmployee.Id); } else { // Active attendance exists, returning last attendance with details response = _mapper.Map(jobAttendance); _logger.LogInfo("Latest attendance fetched for EmployeeId: {EmployeeId} on JobTicketId: {JobTicketId}", loggedInEmployee.Id, jobTicketId); } // Return success with the constructed response return ApiResponse.SuccessResponse(response, "Latest job tagging for current employee fetched successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception in GetAttendanceForSelfAsync for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, jobTicketId); return ApiResponse.ErrorResponse("An unexpected error occurred.", ex.Message, 500); } } public async Task> GetAttendanceLogForAttendanceAsync(Guid jobAttendanceId, Employee loggedInEmployee, Guid tenantId) { _logger.LogInfo("GetAttendanceLogForAttendanceAsync called for JobAttendanceId: {JobAttendanceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", jobAttendanceId, tenantId, loggedInEmployee.Id); try { // Validate existence of the JobAttendance record for the tenant var jobAttendance = await _context.JobAttendance .AsNoTracking() .FirstOrDefaultAsync(ja => ja.Id == jobAttendanceId && ja.TenantId == tenantId); if (jobAttendance == null) { _logger.LogWarning("JobAttendance not found. JobAttendanceId: {JobAttendanceId}, TenantId: {TenantId}", jobAttendanceId, tenantId); return ApiResponse.ErrorResponse("Job attendance not found", "Job attendance not found", 404); } // Fetch related attendance logs including JobTicket status and Employee role details var attendanceLogs = await _context.JobAttendanceLogs .AsNoTracking() .Include(jal => jal.JobTicket).ThenInclude(jt => jt!.Status) .Include(jal => jal.Employee).ThenInclude(e => e!.JobRole) .Where(jal => jal.JobAttendanceId == jobAttendanceId && jal.TenantId == tenantId) .ToListAsync(); // If no logs found, return empty list with success message if (!attendanceLogs.Any()) { _logger.LogInfo("No attendance logs found for JobAttendanceId: {JobAttendanceId}", jobAttendanceId); return ApiResponse.SuccessResponse(new List(), "Job attendance log fetched successfully", 200); } // Extract document IDs from logs that have attached documents var documentIds = attendanceLogs.Where(log => log.DocumentId.HasValue) .Select(log => log.DocumentId!.Value) .Distinct() .ToList(); // Fetch documents related to the extracted document IDs and tenant var documents = await _context.Documents .AsNoTracking() .Where(d => documentIds.Contains(d.Id) && d.TenantId == tenantId) .ToListAsync(); // Map each attendance log and enrich with document info including pre-signed URLs var response = attendanceLogs.Select(log => { var logVm = _mapper.Map(log); if (log.DocumentId.HasValue) { var document = documents.FirstOrDefault(d => d.Id == log.DocumentId.Value); if (document != null) { var preSignedUrl = _s3Service.GeneratePreSignedUrl(document.S3Key); var thumbPreSignedUrl = !string.IsNullOrWhiteSpace(document.ThumbS3Key) ? _s3Service.GeneratePreSignedUrl(document.ThumbS3Key) : preSignedUrl; logVm.Document = _mapper.Map(document); logVm.Document.PreSignedUrl = preSignedUrl; logVm.Document.ThumbPreSignedUrl = thumbPreSignedUrl; } } return logVm; }).ToList(); _logger.LogInfo("Job attendance log fetched successfully. JobAttendanceId: {JobAttendanceId}, RecordsCount: {Count}", jobAttendanceId, response.Count); return ApiResponse.SuccessResponse(response, "Job attendance log fetched successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception in GetAttendanceLogForAttendanceAsync for JobAttendanceId: {JobAttendanceId}, TenantId: {TenantId}", jobAttendanceId, tenantId); return ApiResponse.ErrorResponse("An unexpected error occurred.", ex.Message, 500); } } public async Task> GetAttendanceForJobTeamAsync(Guid jobTicketId, DateTime? startDate, DateTime? endDate, Employee loggedInEmployee, Guid tenantId) { _logger.LogInfo("GetAttendanceForJobTeamAsync called for JobTicketId: {JobTicketId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", jobTicketId, tenantId, loggedInEmployee.Id); try { // Validate the existence and active status of the job ticket including its status related data var jobTicket = await _context.JobTickets .AsNoTracking() .FirstOrDefaultAsync(jt => jt.Id == jobTicketId && jt.TenantId == tenantId && jt.IsActive); if (jobTicket == null) { _logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", jobTicketId, tenantId); return ApiResponse.ErrorResponse("Job is not found", "Job is not found", 404); } // Define date range for attendance query: default from today to next 7 days if not provided DateTime fromDate = startDate?.Date ?? DateTime.UtcNow.Date; DateTime toDate = endDate?.Date ?? fromDate.AddDays(7); // Fetch attendance records within the date range for the specified job ticket and tenant var attendances = await _context.JobAttendance .AsNoTracking() .Include(ja => ja.JobTicket).ThenInclude(jt => jt!.Status) .Include(ja => ja.Employee).ThenInclude(e => e!.JobRole) .Where(ja => ja.JobTcketId == jobTicketId && ja.TaggedInTime.Date >= fromDate && ja.TaggedInTime.Date <= toDate && ja.TenantId == tenantId) .ToListAsync(); // Map attendance entities to view models var response = attendances.Select(ja => { var result = _mapper.Map(ja); // Determine if current attendance record is not the latest, if so clear NextAction var isNotLast = attendances.Any(attendance => attendance.TaggedInTime.Date > ja.TaggedInTime.Date); if (isNotLast || (ja.TaggedOutTime.HasValue && ja.TaggedInTime.Date != DateTime.UtcNow.Date) || ja.EmployeeId != loggedInEmployee.Id) { result.NextAction = null; } return result; }).ToList(); _logger.LogInfo("Attendance for job team fetched successfully. JobTicketId: {JobTicketId}, RecordsCount: {Count}", jobTicketId, response.Count); return ApiResponse.SuccessResponse(response, "Attendance for job team fetched successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "Error occurred in GetAttendanceForJobTeamAsync for JobTicketId: {JobTicketId}, TenantId: {TenantId}", jobTicketId, tenantId); return ApiResponse.ErrorResponse("An unexpected error occurred.", ex.Message, 500); } } public async Task> ManageJobTaggingAsync(JobAttendanceDto model, Employee loggedInEmployee, Guid tenantId) { _logger.LogInfo("ManageJobTaggingAsync called for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId); try { // Validate the job ticket existence and status var jobTicket = await _context.JobTickets .AsNoTracking() .Include(jt => jt.Status) .FirstOrDefaultAsync(jt => jt.Id == model.JobTcketId && jt.TenantId == tenantId && jt.IsActive); if (jobTicket == null) { _logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", model.JobTcketId, tenantId); return ApiResponse.ErrorResponse("Job not found", "Job not found", 404); } // Check if the current user is part of the job team var jobEmployeeMapping = await _context.JobEmployeeMappings .AsNoTracking() .FirstOrDefaultAsync(jem => jem.JobTicketId == model.JobTcketId && jem.AssigneeId == loggedInEmployee.Id && jem.TenantId == tenantId); if (jobEmployeeMapping == null) { _logger.LogWarning("User is not part of job team. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId); return ApiResponse.ErrorResponse("User is not part of job team", "User is not part of job team", 400); } // Get the last attendance record for the user and job var jobAttendance = await _context.JobAttendance .AsNoTracking() .Where(ja => ja.EmployeeId == loggedInEmployee.Id && ja.JobTcketId == model.JobTcketId) .OrderByDescending(ja => ja.TaggedInAt) .FirstOrDefaultAsync(); JobAttendance updateJobAttendance; DateTime markedAt = model.MarkedAt ?? DateTime.UtcNow; DateTime currentTime = DateTime.UtcNow; // Handle TAG_IN action if (model.Action == TAGGING_MARK_TYPE.TAG_IN) { var isLastTaggedOut = jobAttendance != null && jobAttendance.Action == TAGGING_MARK_TYPE.TAG_OUT && jobAttendance.TaggedOutTime != null; if (jobAttendance == null || (isLastTaggedOut && jobAttendance.TaggedInTime.Date != currentTime.Date)) { // Create new JobAttendance record for Tag In var newJobAttendance = new JobAttendance { Id = Guid.NewGuid(), JobTcketId = model.JobTcketId, EmployeeId = loggedInEmployee.Id, Action = TAGGING_MARK_TYPE.TAG_IN, TaggedInTime = markedAt, TaggedInAt = currentTime, TenantId = tenantId }; _context.JobAttendance.Add(newJobAttendance); updateJobAttendance = newJobAttendance; _logger.LogInfo("New Tag In created for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId); } else if (isLastTaggedOut && jobAttendance.TaggedInTime.Date == currentTime.Date) { // Update the existing last JobAttendance to Tag In jobAttendance.Action = TAGGING_MARK_TYPE.TAG_IN; jobAttendance.TaggedInTime = markedAt; jobAttendance.TaggedInAt = currentTime; jobAttendance.TaggedOutTime = null; jobAttendance.TaggedOutAt = null; _context.JobAttendance.Update(jobAttendance); updateJobAttendance = jobAttendance; _logger.LogInfo("Existing JobAttendance updated to Tag In for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId); } else { _logger.LogWarning("Attempted to Tag In without tagging out last session. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId); return ApiResponse.ErrorResponse("First, mark the last tag as out before tagging in.", "First, mark the last tag as out before tagging in.", 400); } } // Handle TAG_OUT action else if (model.Action == TAGGING_MARK_TYPE.TAG_OUT) { if (jobAttendance != null && jobAttendance.Action == TAGGING_MARK_TYPE.TAG_IN && jobAttendance.TaggedOutTime == null) { jobAttendance.Action = TAGGING_MARK_TYPE.TAG_OUT; jobAttendance.TaggedOutTime = markedAt; jobAttendance.TaggedOutAt = currentTime; _context.JobAttendance.Update(jobAttendance); updateJobAttendance = jobAttendance; _logger.LogInfo("JobAttendance updated to Tag Out for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId); } else { _logger.LogWarning("Attempted to Tag Out without previous Tag In. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId); return ApiResponse.ErrorResponse("First, mark the last tag as in before tagging out.", "First, mark the last tag as in before tagging out.", 400); } } else { _logger.LogWarning("Invalid action provided: {Action}. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", model.Action, loggedInEmployee.Id, model.JobTcketId); return ApiResponse.ErrorResponse("Provided invalid action", "Provided invalid action", 400); } Document? document = null; // Handle attachment upload if present if (model.Attachment != null && model.Attachment.ContentType != null) { string base64 = model.Attachment.Base64Data?.Split(',').LastOrDefault() ?? ""; if (string.IsNullOrWhiteSpace(base64)) { _logger.LogWarning("Base64 data missing in attachment. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId); return ApiResponse.ErrorResponse("Base64 data is missing", "Attachment data missing", 400); } var fileType = _s3Service.GetContentTypeFromBase64(base64); var fileName = _s3Service.GenerateFileName(fileType, tenantId, "job_attendance"); var objectKey = $"tenant-{tenantId}/ServiceProject/{jobTicket.ProjectId}/Job/{jobTicket.Id}/Attendance/{updateJobAttendance.Id}/{fileName}"; await _s3Service.UploadFileAsync(base64, fileType, objectKey); document = new Document { BatchId = Guid.NewGuid(), UploadedById = loggedInEmployee.Id, FileName = model.Attachment.FileName ?? "", ContentType = model.Attachment.ContentType, S3Key = objectKey, FileSize = model.Attachment.FileSize, UploadedAt = currentTime, TenantId = tenantId }; _context.Documents.Add(document); _logger.LogInfo("Attachment uploaded and document record created. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}, DocumentId: {DocumentId}", loggedInEmployee.Id, model.JobTcketId, document.Id); } // Create attendance log entry for audit trail var attendanceLog = new JobAttendanceLog { Id = Guid.NewGuid(), EmployeeId = updateJobAttendance.EmployeeId, Action = updateJobAttendance.Action, MarkedTIme = markedAt, MarkedAt = currentTime, Latitude = model.Latitude, Longitude = model.Longitude, Comment = model.Comment, JobAttendanceId = updateJobAttendance.Id, JobTcketId = model.JobTcketId, DocumentId = document?.Id, TenantId = tenantId }; _context.JobAttendanceLogs.Add(attendanceLog); // Persist all changes in one save operation await _context.SaveChangesAsync(); var response = _mapper.Map(updateJobAttendance); response.JobTicket = _mapper.Map(jobTicket); response.Employee = _mapper.Map(loggedInEmployee); _logger.LogInfo("Tagging managed successfully for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId); return ApiResponse.SuccessResponse(response, "Tagging managed successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "Error occurred in ManageJobTaggingAsync for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId); return ApiResponse.ErrorResponse("An unexpected error occurred.", ex.Message, 500); } } #endregion #region =================================================================== Helper Functions =================================================================== //private async Task DeleteTalkingPointAttachments(List documentIds) //{ // using var scope = _serviceScopeFactory.CreateScope(); // var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); // var attachmentTask = Task.Run(async () => // { // await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); // var attachments = await dbContext.TalkingPointAttachments.AsNoTracking().Where(ba => documentIds.Contains(ba.DocumentId)).ToListAsync(); // dbContext.TalkingPointAttachments.RemoveRange(attachments); // await dbContext.SaveChangesAsync(); // }); // var documentsTask = Task.Run(async () => // { // await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); // var documents = await dbContext.Documents.AsNoTracking().Where(ba => documentIds.Contains(ba.Id)).ToListAsync(); // if (documents.Any()) // { // dbContext.Documents.RemoveRange(documents); // await dbContext.SaveChangesAsync(); // List deletionObject = new List(); // foreach (var document in documents) // { // deletionObject.Add(new S3DeletionObject // { // Key = document.S3Key // }); // if (!string.IsNullOrWhiteSpace(document.ThumbS3Key) && document.ThumbS3Key != document.S3Key) // { // deletionObject.Add(new S3DeletionObject // { // Key = document.ThumbS3Key // }); // } // } // await _updateLogHelper.PushToS3DeletionAsync(deletionObject); // } // }); // await Task.WhenAll(attachmentTask, documentsTask); //} private async Task DeleteJobAttachemnts(List documentIds) { using var scope = _serviceScopeFactory.CreateScope(); var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); var attachmentTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); var attachments = await dbContext.JobAttachments.AsNoTracking().Where(ba => documentIds.Contains(ba.DocumentId)).ToListAsync(); dbContext.JobAttachments.RemoveRange(attachments); await dbContext.SaveChangesAsync(); }); var documentsTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); var documents = await dbContext.Documents.AsNoTracking().Where(ba => documentIds.Contains(ba.Id)).ToListAsync(); if (documents.Any()) { dbContext.Documents.RemoveRange(documents); await dbContext.SaveChangesAsync(); List deletionObject = new List(); foreach (var document in documents) { deletionObject.Add(new S3DeletionObject { Key = document.S3Key }); if (!string.IsNullOrWhiteSpace(document.ThumbS3Key) && document.ThumbS3Key != document.S3Key) { deletionObject.Add(new S3DeletionObject { Key = document.ThumbS3Key }); } } await _updateLogHelper.PushToS3DeletionAsync(deletionObject); } }); await Task.WhenAll(attachmentTask, documentsTask); } /// /// 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 } }