From 2f04339b4db92d7fba86624b6f72b34080270c06 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Fri, 14 Nov 2025 10:04:51 +0530 Subject: [PATCH] Added the Attachments in job comments APIs and removed list of comments from job tickets details API --- .../Dtos/ServiceProject/JobCommentDto.cs | 5 +- .../ViewModels/ServiceProject/JobCommentVM.cs | 2 +- .../ServiceProject/JobTicketDetailsVM.cs | 1 - .../MappingProfiles/MappingProfile.cs | 1 + Marco.Pms.Services/Service/MasterService.cs | 6 +- .../Service/ServiceProjectService.cs | 313 +++++++++++------- 6 files changed, 203 insertions(+), 125 deletions(-) diff --git a/Marco.Pms.Model/Dtos/ServiceProject/JobCommentDto.cs b/Marco.Pms.Model/Dtos/ServiceProject/JobCommentDto.cs index 0e738f0..9b874a9 100644 --- a/Marco.Pms.Model/Dtos/ServiceProject/JobCommentDto.cs +++ b/Marco.Pms.Model/Dtos/ServiceProject/JobCommentDto.cs @@ -1,8 +1,11 @@ -namespace Marco.Pms.Model.Dtos.ServiceProject +using Marco.Pms.Model.Utilities; + +namespace Marco.Pms.Model.Dtos.ServiceProject { public class JobCommentDto { public required Guid JobTicketId { get; set; } public required string Comment { get; set; } + public List? Attachments { get; set; } } } diff --git a/Marco.Pms.Model/ViewModels/ServiceProject/JobCommentVM.cs b/Marco.Pms.Model/ViewModels/ServiceProject/JobCommentVM.cs index 3620fed..3b5e700 100644 --- a/Marco.Pms.Model/ViewModels/ServiceProject/JobCommentVM.cs +++ b/Marco.Pms.Model/ViewModels/ServiceProject/JobCommentVM.cs @@ -13,6 +13,6 @@ namespace Marco.Pms.Model.ViewModels.ServiceProject public BasicEmployeeVM? CreatedBy { get; set; } public DateTime? UpdatedAt { get; set; } public BasicEmployeeVM? UpdatedBy { get; set; } - public List? Documents { get; set; } + public List? Attachments { get; set; } } } diff --git a/Marco.Pms.Model/ViewModels/ServiceProject/JobTicketDetailsVM.cs b/Marco.Pms.Model/ViewModels/ServiceProject/JobTicketDetailsVM.cs index 8cbea1e..ead89df 100644 --- a/Marco.Pms.Model/ViewModels/ServiceProject/JobTicketDetailsVM.cs +++ b/Marco.Pms.Model/ViewModels/ServiceProject/JobTicketDetailsVM.cs @@ -19,6 +19,5 @@ namespace Marco.Pms.Model.ViewModels.ServiceProject public BasicEmployeeVM? CreatedBy { get; set; } public List? Tags { get; set; } public List? UpdateLogs { get; set; } - public List? Comments { get; set; } } } diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 8b45d37..744e8e3 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -499,6 +499,7 @@ namespace Marco.Pms.Services.MappingProfiles #region ======================================================= Document ======================================================= + CreateMap(); CreateMap() .ForMember( dest => dest.DocumentId, diff --git a/Marco.Pms.Services/Service/MasterService.cs b/Marco.Pms.Services/Service/MasterService.cs index dccf0d9..f4d5da3 100644 --- a/Marco.Pms.Services/Service/MasterService.cs +++ b/Marco.Pms.Services/Service/MasterService.cs @@ -67,11 +67,7 @@ namespace Marco.Pms.Services.Service /// Employee requesting the statuses. /// Tenant identifier for multi-tenant scope. /// ApiResponse containing list of job statuses or error details. - public async Task> GetJobStatusAsync( - Guid? statusId, - Guid? projectId, - Employee loggedInEmployee, - Guid tenantId) + public async Task> GetJobStatusAsync(Guid? statusId, Guid? projectId, Employee loggedInEmployee, Guid tenantId) { _logger.LogDebug("GetJobStatusAsync called by employee {EmployeeId} in tenant {TenantId}", loggedInEmployee.Id, tenantId); diff --git a/Marco.Pms.Services/Service/ServiceProjectService.cs b/Marco.Pms.Services/Service/ServiceProjectService.cs index 31ac128..ab8454a 100644 --- a/Marco.Pms.Services/Service/ServiceProjectService.cs +++ b/Marco.Pms.Services/Service/ServiceProjectService.cs @@ -1,15 +1,16 @@ 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.Helpers; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; @@ -23,7 +24,7 @@ namespace Marco.Pms.Services.Service private readonly IServiceScopeFactory _serviceScopeFactory; private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate private readonly ILoggingService _logger; - private readonly CacheUpdateHelper _cache; + private readonly S3UploadService _s3Service; private readonly IMapper _mapper; private readonly Guid NewStatus = Guid.Parse("32d76a02-8f44-4aa0-9b66-c3716c45a918"); @@ -33,13 +34,13 @@ namespace Marco.Pms.Services.Service IServiceScopeFactory serviceScopeFactory, ApplicationDbContext context, ILoggingService logger, - CacheUpdateHelper cache, + S3UploadService s3Service, IMapper mapper) { _serviceScopeFactory = serviceScopeFactory; _context = context; _logger = logger; - _cache = cache; + _s3Service = s3Service; _mapper = mapper; _dbContextFactory = dbContextFactory; } @@ -560,15 +561,6 @@ namespace Marco.Pms.Services.Service .ToListAsync(); }); - var commentTask = Task.Run(async () => - { - await using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.JobComments - .Include(jc => jc.CreatedBy).ThenInclude(e => e!.JobRole) - .Where(jc => jc.JobTicketId == id && jc.TenantId == tenantId) - .ToListAsync(); - }); - var updateLogTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); @@ -578,7 +570,7 @@ namespace Marco.Pms.Services.Service .ToListAsync(); }); - await Task.WhenAll(assigneeTask, tagTask, commentTask, updateLogTask); + await Task.WhenAll(assigneeTask, tagTask, updateLogTask); // Map update logs with status descriptions var jobUpdateLogVMs = updateLogTask.Result.Select(ul => @@ -595,13 +587,7 @@ namespace Marco.Pms.Services.Service }; }).ToList(); - // Map comments, assignees, and tags to their respective viewmodels - var commentVMs = _mapper.Map>(commentTask.Result); - commentVMs = commentVMs.Select(vm => - { - vm.JobTicket = _mapper.Map(jobTicket); - return vm; - }).ToList(); + // Map assignees, and tags to their respective viewmodels var assigneeVMs = _mapper.Map>(assigneeTask.Result); var tagVMs = _mapper.Map>(tagTask.Result); @@ -610,7 +596,6 @@ namespace Marco.Pms.Services.Service response.Assignees = assigneeVMs; response.Tags = tagVMs; response.UpdateLogs = jobUpdateLogVMs; - response.Comments = commentVMs; _logger.LogInfo("Job ticket details assembled successfully for JobTicketId: {JobTicketId}", id); @@ -623,91 +608,6 @@ namespace Marco.Pms.Services.Service } } - /// - /// Retrieves a paginated list of comments for a specified job ticket within a tenant context. - /// - /// Optional job ticket identifier to filter comments. - /// Page number (1-based index) for pagination. - /// Page size for pagination. - /// Employee making the request (for logging). - /// Tenant context to scope data. - /// ApiResponse with paged comment view models 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) - .Where(jc => - jc.TenantId == tenantId && - jc.JobTicket != null && - jc.CreatedBy != null && - jc.CreatedBy.JobRole != null); - - // Validate and filter by job ticket if specified - 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); - } - - var totalEntities = await commentQuery.CountAsync(); - var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize); - - var comments = await commentQuery - .OrderByDescending(jc => jc.CreatedAt) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); - - var commentVMs = _mapper.Map>(comments); - - 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); - } - } - /// /// Retrieves all job tags associated with the tenant, ordered alphabetically by name. /// @@ -928,21 +828,145 @@ namespace Marco.Pms.Services.Service } } + #endregion + #region =================================================================== Job Comments Functions =================================================================== + /// - /// Adds a new comment to an existing job ticket within the tenant context. + /// Retrieves a paginated list of comments with attachments for a specified job ticket within a tenant context. /// - /// Comment data transfer object containing the job ticket ID and comment text. - /// Employee adding the comment (for auditing). - /// Tenant identifier to enforce multi-tenancy. - /// ApiResponse with the created comment view or error details. + /// 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); @@ -953,7 +977,7 @@ namespace Marco.Pms.Services.Service { _logger.LogInfo("Attempting to add comment to job ticket {JobTicketId} by employee {EmployeeId}", model.JobTicketId, loggedInEmployee.Id); - // Verify if the job ticket exists within tenant and load status for response mapping + // Verify the job ticket's existence and load minimal required info var jobTicket = await _context.JobTickets .Include(jt => jt.Status) .AsNoTracking() @@ -965,22 +989,78 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Job Not Found", "Job ticket not found or inaccessible.", 404); } - // Create and save new comment entity + // 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(); - // Map response including basic job ticket info + // 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}", @@ -996,7 +1076,6 @@ namespace Marco.Pms.Services.Service } } - #endregion } }