diff --git a/Marco.Pms.Model/Dtos/ServiceProject/JobCommentDto.cs b/Marco.Pms.Model/Dtos/ServiceProject/JobCommentDto.cs index 9b874a9..beebe46 100644 --- a/Marco.Pms.Model/Dtos/ServiceProject/JobCommentDto.cs +++ b/Marco.Pms.Model/Dtos/ServiceProject/JobCommentDto.cs @@ -4,6 +4,7 @@ namespace Marco.Pms.Model.Dtos.ServiceProject { public class JobCommentDto { + public Guid? Id { get; set; } public required Guid JobTicketId { get; set; } public required string Comment { get; set; } public List? Attachments { get; set; } diff --git a/Marco.Pms.Services/Controllers/ServiceProjectController.cs b/Marco.Pms.Services/Controllers/ServiceProjectController.cs index 1bf7f27..7b7327b 100644 --- a/Marco.Pms.Services/Controllers/ServiceProjectController.cs +++ b/Marco.Pms.Services/Controllers/ServiceProjectController.cs @@ -38,8 +38,8 @@ namespace Marco.Pms.Services.Controllers [HttpGet("list")] public async Task GetServiceProjectList([FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.GetServiceProjectListAsync(pageNumber, pageSize, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.GetServiceProjectListAsync(pageNumber, pageSize, loggedInEmployee, tenantId); return StatusCode(response.StatusCode, response); } @@ -47,8 +47,8 @@ namespace Marco.Pms.Services.Controllers [HttpGet("details/{id}")] public async Task GetServiceProjectDetails(Guid id) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.GetServiceProjectDetailsAsync(id, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.GetServiceProjectDetailsAsync(id, loggedInEmployee, tenantId); return StatusCode(response.StatusCode, response); } @@ -56,11 +56,11 @@ namespace Marco.Pms.Services.Controllers [HttpPost("create")] public async Task CreateProject([FromBody] ServiceProjectDto serviceProject) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.CreateServiceProjectAsync(serviceProject, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.CreateServiceProjectAsync(serviceProject, loggedInEmployee, tenantId); if (response.Success) { - var notification = new { LoggedInUserId = loggedInEmploee.Id, Keyword = "Service_Project", Response = response.Data }; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Service_Project", Response = response.Data }; await _signalR.SendNotificationAsync(notification); } return StatusCode(response.StatusCode, response); @@ -71,11 +71,11 @@ namespace Marco.Pms.Services.Controllers [HttpPut("edit/{id}")] public async Task UpdateServicecProject(Guid id, [FromBody] ServiceProjectDto serviceProject) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.UpdateServiceProjectAsync(id, serviceProject, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.UpdateServiceProjectAsync(id, serviceProject, loggedInEmployee, tenantId); if (response.Success) { - var notification = new { LoggedInUserId = loggedInEmploee.Id, Keyword = "Service_Project", Response = response.Data }; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Service_Project", Response = response.Data }; await _signalR.SendNotificationAsync(notification); } return StatusCode(response.StatusCode, response); @@ -85,11 +85,11 @@ namespace Marco.Pms.Services.Controllers [HttpDelete("delete/{id}")] public async Task DeActivateServiceProject(Guid id, bool isActive = false) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.DeActivateServiceProjectAsync(id, isActive, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.DeActivateServiceProjectAsync(id, isActive, loggedInEmployee, tenantId); if (response.Success) { - var notification = new { LoggedInUserId = loggedInEmploee.Id, Keyword = "Service_Project", Response = response.Data }; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Service_Project", Response = response.Data }; await _signalR.SendNotificationAsync(notification); } return StatusCode(response.StatusCode, response); @@ -101,19 +101,19 @@ namespace Marco.Pms.Services.Controllers [HttpGet("get/allocation/list")] public async Task GetServiceProjectAllocationList([FromQuery] Guid? projectId, [FromQuery] Guid? employeeId, [FromQuery] bool isActive = true) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.GetServiceProjectAllocationListAsync(projectId, employeeId, true, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.GetServiceProjectAllocationListAsync(projectId, employeeId, true, loggedInEmployee, tenantId); return StatusCode(response.StatusCode, response); } [HttpPost("manage/allocation")] public async Task ManageServiceProjectAllocation([FromBody] List model) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.ManageServiceProjectAllocationAsync(model, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.ManageServiceProjectAllocationAsync(model, loggedInEmployee, tenantId); if (response.Success) { - var notification = new { LoggedInUserId = loggedInEmploee.Id, Keyword = "Service_Project_Allocation", Response = response.Data }; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Service_Project_Allocation", Response = response.Data }; await _signalR.SendNotificationAsync(notification); } return StatusCode(response.StatusCode, response); @@ -125,8 +125,8 @@ namespace Marco.Pms.Services.Controllers [HttpGet("job/list")] public async Task GetJobTicketsList([FromQuery] Guid? projectId, [FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20, [FromQuery] bool isActive = true) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.GetJobTicketsListAsync(projectId, pageNumber, pageSize, isActive, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.GetJobTicketsListAsync(projectId, pageNumber, pageSize, isActive, loggedInEmployee, tenantId); return StatusCode(response.StatusCode, response); } @@ -134,8 +134,8 @@ namespace Marco.Pms.Services.Controllers [HttpGet("job/details/{id}")] public async Task GetJobTicketDetails(Guid id) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.GetJobTicketDetailsAsync(id, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.GetJobTicketDetailsAsync(id, loggedInEmployee, tenantId); return StatusCode(response.StatusCode, response); } @@ -143,8 +143,8 @@ namespace Marco.Pms.Services.Controllers [HttpGet("job/tag/list")] public async Task GetJobTagList() { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.GetJobTagListAsync(loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.GetJobTagListAsync(loggedInEmployee, tenantId); return StatusCode(response.StatusCode, response); } @@ -152,11 +152,11 @@ namespace Marco.Pms.Services.Controllers [HttpPost("job/create")] public async Task CreateJobTicket([FromBody] CreateJobTicketDto model) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.CreateJobTicketAsync(model, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.CreateJobTicketAsync(model, loggedInEmployee, tenantId); if (response.Success) { - var notification = new { LoggedInUserId = loggedInEmploee.Id, Keyword = "Job_Ticket", Response = response.Data }; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Job_Ticket", Response = response.Data }; await _signalR.SendNotificationAsync(notification); } return StatusCode(response.StatusCode, response); @@ -165,11 +165,11 @@ namespace Marco.Pms.Services.Controllers [HttpPost("job/status-change")] public async Task ChangeJobsStatus([FromBody] ChangeJobStatusDto model) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.ChangeJobsStatusAsync(model, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.ChangeJobsStatusAsync(model, loggedInEmployee, tenantId); if (response.Success) { - var notification = new { LoggedInUserId = loggedInEmploee.Id, Keyword = "Job_Ticket", Response = response.Data }; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Job_Ticket", Response = response.Data }; await _signalR.SendNotificationAsync(notification); } return StatusCode(response.StatusCode, response); @@ -226,8 +226,8 @@ namespace Marco.Pms.Services.Controllers [HttpGet("job/comment/list")] public async Task GetCommentListByJobTicket([FromQuery] Guid? jobTicketId, [FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.GetCommentListByJobTicketAsync(jobTicketId, pageNumber, pageSize, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.GetCommentListByJobTicketAsync(jobTicketId, pageNumber, pageSize, loggedInEmployee, tenantId); return StatusCode(response.StatusCode, response); } @@ -235,11 +235,24 @@ namespace Marco.Pms.Services.Controllers [HttpPost("job/add/comment")] public async Task AddCommentToJobTicket([FromBody] JobCommentDto model) { - Employee loggedInEmploee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.AddCommentToJobTicketAsync(model, loggedInEmploee, tenantId); + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.AddCommentToJobTicketAsync(model, loggedInEmployee, tenantId); if (response.Success) { - var notification = new { LoggedInUserId = loggedInEmploee.Id, Keyword = "Job_Ticket_Comment", Response = response.Data }; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Job_Ticket_Comment", Response = response.Data }; + await _signalR.SendNotificationAsync(notification); + } + return StatusCode(response.StatusCode, response); + } + + [HttpPut("job/edit/comment")] + public async Task UpdateComment(Guid id, [FromBody] JobCommentDto model) + { + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _serviceProject.UpdateCommentAsync(id, model, loggedInEmployee, tenantId); + if (response.Success) + { + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Job_Ticket_Comment", Response = response.Data }; await _signalR.SendNotificationAsync(notification); } return StatusCode(response.StatusCode, response); diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs index 415005a..5d3e625 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs @@ -33,6 +33,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces #region =================================================================== Job Comments Functions =================================================================== Task> GetCommentListByJobTicketAsync(Guid? jobTicketId, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId); Task> AddCommentToJobTicketAsync(JobCommentDto model, Employee loggedInEmployee, Guid tenantId); + Task> UpdateCommentAsync(Guid id, JobCommentDto model, Employee loggedInEmployee, Guid tenantId); #endregion #region =================================================================== Helper Functions =================================================================== diff --git a/Marco.Pms.Services/Service/ServiceProjectService.cs b/Marco.Pms.Services/Service/ServiceProjectService.cs index d6e43a8..8641473 100644 --- a/Marco.Pms.Services/Service/ServiceProjectService.cs +++ b/Marco.Pms.Services/Service/ServiceProjectService.cs @@ -377,6 +377,11 @@ namespace Marco.Pms.Services.Service 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; @@ -442,7 +447,16 @@ namespace Marco.Pms.Services.Service .ToListAsync(); }); - await Task.WhenAll(serviceProjectTask, servicesTask); + // 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; @@ -490,11 +504,25 @@ namespace Marco.Pms.Services.Service 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); @@ -1176,7 +1204,6 @@ namespace Marco.Pms.Services.Service } 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); @@ -1285,6 +1312,9 @@ namespace Marco.Pms.Services.Service 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 @@ -1313,9 +1343,6 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Job status not found", "Job status not found", 404); } - // Begin database transaction for atomicity - await using var transaction = await _context.Database.BeginTransactionAsync(); - // Handle status change with validation and log creation if (jobTicket.StatusId != model.StatusId) { @@ -1501,6 +1528,7 @@ namespace Marco.Pms.Services.Service } 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); @@ -1706,8 +1734,8 @@ namespace Marco.Pms.Services.Service // 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}"; + 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); @@ -1762,10 +1790,277 @@ namespace Marco.Pms.Services.Service } } + /// + /// 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 DeleteAttachemnts(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 =================================================================== Helper Functions =================================================================== + private async Task DeleteAttachemnts(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. ///