using AutoMapper; using Marco.Pms.DataAccess.Data; 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.ServiceProject; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; 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; 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 =================================================================== public async Task> GetServiceProjectListAsync(int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId) { try { 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 totalEntites = await serviceProjectQuery.CountAsync(); var totalPages = (int)Math.Ceiling((double)totalEntites / pageSize); var serviceProjects = await serviceProjectQuery .OrderByDescending(e => e.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); var serviceProjectIds = serviceProjects.Select(sp => sp.Id).ToList(); var serviceProjectServiceMappings = await _context.ServiceProjectServiceMapping .Include(sps => sps.Service) .Where(sps => serviceProjectIds.Contains(sps.ProjectId) && sps.Service != null && sps.TenantId == tenantId) .ToListAsync(); var serviceProjectVMs = serviceProjects.Select(sp => { var services = serviceProjectServiceMappings.Where(sps => sps.ProjectId == sp.Id).Select(sps => sps.Service!).ToList(); var result = _mapper.Map(sp); result.Services = _mapper.Map>(services); return result; }).ToList(); var response = new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntites = totalEntites, Data = serviceProjectVMs, }; _logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", serviceProjectVMs.Count); return ApiResponse.SuccessResponse(response, "Projects retrieved successfully.", 200); } catch (Exception ex) { // --- Step 5: Graceful Error Handling --- _logger.LogError(ex, "An unexpected error occurred in GetAllProjects for tenant {TenantId}.", tenantId); return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); } } public async Task> GetServiceProjectDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId) { 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) .FirstOrDefaultAsync(sp => sp.Id == id && sp.TenantId == tenantId); if (serviceProject == null) { return ApiResponse.ErrorResponse("Service Project not found", "Service Project not found", 404); } 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(); //var numberOfJobs = await _context.JobTickets.Where(jt => jt.ProjectId == serviceProject.Id && jt.TenantId == tenantId).CountAsync(); var response = _mapper.Map(serviceProject); response.Services = _mapper.Map>(services); //response.NumberOfJobs = numberOfJobs; response.NumberOfJobs = 0; return ApiResponse.SuccessResponse(response, "Service Project Details fetched successfully", 200); } public async Task> CreateServiceProjectAsync(ServiceProjectDto model, Employee loggedInEmployee, Guid tenantId) { var serviceIds = model.Services.Where(s => s.IsActive).Select(s => s.ServiceId).ToList(); 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; if (client == null) { return ApiResponse.ErrorResponse("Client not found", "Client not found", 404); } if (status == null) { return ApiResponse.ErrorResponse("Project Status not found", "Project Status not found", 404); } var serviceProject = _mapper.Map(model); serviceProject.Id = Guid.NewGuid(); serviceProject.CreatedById = loggedInEmployee.Id; serviceProject.CreatedAt = DateTime.UtcNow; serviceProject.IsActive = true; serviceProject.TenantId = tenantId; var projectServiceMapping = model.Services.Where(sdto => services.Any(s => s.Id == sdto.ServiceId)).Select(sdto => new ServiceProjectServiceMapping { ServiceId = sdto.ServiceId, ProjectId = serviceProject.Id, TenantId = tenantId }).ToList(); try { _context.ServiceProjects.Add(serviceProject); _context.ServiceProjectServiceMapping.AddRange(projectServiceMapping); await _context.SaveChangesAsync(); _logger.LogInfo("Service Project {ProjectId} created successfully for TenantId={TenantId}, by Employee {EmployeeId}.", serviceProject.Id, tenantId, loggedInEmployee); var serviceProjectVM = _mapper.Map(serviceProject); serviceProjectVM.Client = _mapper.Map(client); serviceProjectVM.Status = status; serviceProjectVM.Services = services.Where(s => serviceIds.Contains(s.Id)).Select(s => _mapper.Map(s)).ToList(); serviceProjectVM.CreatedBy = _mapper.Map(loggedInEmployee); return ApiResponse.SuccessResponse(serviceProjectVM, "An Successfullly occurred while saving the project.", 201); } catch (Exception ex) { _logger.LogError(ex, "DB Failure: Service Project creation failed for TenantId={TenantId}. Rolling back.", tenantId); return ApiResponse.ErrorResponse("An error occurred while saving the project.", ex.Message, 500); } } public async Task> UpdateServiceProjectAsync(Guid id, ServiceProjectDto model, Employee loggedInEmployee, Guid tenantId) { try { var serviceIds = model.Services.Select(s => s.ServiceId).ToList(); 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; if (client == null) { return ApiResponse.ErrorResponse("Client not found", "Client not found", 404); } if (status == null) { return ApiResponse.ErrorResponse("Project Status not found", "Project Status not found", 404); } 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 user {UserId}.", id, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404); } _mapper.Map(model, serviceProject); serviceProject.UpdatedAt = DateTime.UtcNow; serviceProject.UpdatedById = loggedInEmployee.Id; var serviceProjectServiceMappings = await _context.ServiceProjectServiceMapping .AsNoTracking() .Where(sps => sps.ProjectId == serviceProject.Id && sps.TenantId == tenantId) .ToListAsync(); var newMapping = new List(); var removedMapping = new List(); foreach (var dto in model.Services) { var serviceProjectServiceMapping = serviceProjectServiceMappings .FirstOrDefault(sps => sps.ServiceId == dto.ServiceId); if (dto.IsActive && serviceProjectServiceMapping == null) { newMapping.Add(new ServiceProjectServiceMapping { Id = Guid.NewGuid(), ServiceId = dto.ServiceId, ProjectId = serviceProject.Id, TenantId = tenantId, }); } else if (!dto.IsActive && serviceProjectServiceMapping != null) { removedMapping.Add(serviceProjectServiceMapping); } } _context.ServiceProjectServiceMapping.AddRange(newMapping); _context.ServiceProjectServiceMapping.RemoveRange(removedMapping); await _context.SaveChangesAsync(); 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.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(); }); await Task.WhenAll(serviceProjectTask, servicesTask); serviceProject = serviceProjectTask.Result; services = servicesTask.Result; ServiceProjectVM serviceProjectVm = _mapper.Map(serviceProject); serviceProjectVm.Services = _mapper.Map>(services); return ApiResponse.SuccessResponse(serviceProjectVm, "Service Project updated successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "An unexpected error occurred in Updating Service Project for tenant {TenantId}.", tenantId); return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); } } public async Task> DeActivateServiceProjectAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId) { try { var serviceProject = await _context.ServiceProjects .Where(sp => sp.Id == id && sp.TenantId == tenantId).FirstOrDefaultAsync(); if (serviceProject == null) { _logger.LogWarning("Attempt to de-activate non-existent Service project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404); } serviceProject.IsActive = isActive; await _context.SaveChangesAsync(); _logger.LogInfo("Successfully de-activated service project {ProjectId}", id); return ApiResponse.SuccessResponse(new { }, "Projects de-activated successfully.", 200); } catch (Exception ex) { // --- Step 5: Graceful Error Handling --- _logger.LogError(ex, "An unexpected error occurred in DeActivateServiceProject for tenant {TenantId}.", tenantId); return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); } } #endregion #region =================================================================== Expense Functions =================================================================== #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); // 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>(assigneeTask.Result); 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; _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; // Map DTO to entity var jobTicket = _mapper.Map(model); jobTicket.Id = Guid.NewGuid(); jobTicket.StatusId = hasAssignees ? AssignedStatus : NewStatus; 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) { await transaction.RollbackAsync(); _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); } } #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"); var objectKey = $"tenant-{tenantId}/ServiceProject/{jobTicket.ProjectId}/Job/{jobTicket.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); } } #endregion } }