using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Activities; using Marco.Pms.Model.Dtos.DocumentManager; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Filters; using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Services.Service; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; using Serilog; using System.Text.Json; namespace Marco.Pms.Services.Controllers { [Route("api/[controller]")] [ApiController] [Authorize] public class ImageController : ControllerBase { private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly S3UploadService _s3Service; private readonly UserHelper _userHelper; private readonly ILoggingService _logger; private readonly PermissionServices _permission; private readonly Guid tenantId; public ImageController(IDbContextFactory dbContextFactory, ApplicationDbContext context, S3UploadService s3Service, UserHelper userHelper, ILoggingService logger, PermissionServices permission) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); _context = context ?? throw new ArgumentNullException(nameof(context)); _s3Service = s3Service ?? throw new ArgumentNullException(nameof(s3Service)); _userHelper = userHelper ?? throw new ArgumentNullException(nameof(userHelper)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _permission = permission ?? throw new ArgumentNullException(nameof(permission)); tenantId = userHelper.GetTenantId(); } [HttpGet("images/{projectId}")] public async Task GetImageList(Guid projectId, [FromQuery] string? filter, [FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 10) { _logger.LogInfo("[GetImageList] Called by Employee for ProjectId: {ProjectId}", projectId); var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 1: Validate project existence var isProjectExist = await _context.Projects.AnyAsync(p => p.Id == projectId && p.TenantId == tenantId); if (!isProjectExist) { _logger.LogWarning("[GetImageList] ProjectId: {ProjectId} not found", projectId); return BadRequest(ApiResponse.ErrorResponse("Project not found", "Project not found in database", 400)); } // Step 2: Check project access permission var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); if (!hasPermission) { _logger.LogWarning("[GetImageList] Access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); return StatusCode(403, ApiResponse.ErrorResponse("You don't have access", "You don't have access", 403)); } // Step 3: Deserialize filter ImageFilter? imageFilter = null; if (!string.IsNullOrWhiteSpace(filter)) { try { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; //string unescapedJsonString = JsonSerializer.Deserialize(filter, options) ?? ""; //imageFilter = JsonSerializer.Deserialize(unescapedJsonString, options); imageFilter = JsonSerializer.Deserialize(filter, options); } catch (Exception ex) { _logger.LogWarning("[GetImageList] Failed to parse filter: {Message}", ex.Message); } } var taskQuery = _context.TaskAllocations .Include(t => t.Employee) .Include(t => t.ReportedBy) .Include(t => t.ApprovedBy) .Include(t => t.WorkStatus) .Include(t => t.WorkItem) .ThenInclude(wi => wi!.ActivityMaster) .ThenInclude(a => a!.ActivityGroup) .ThenInclude(ag => ag!.Service) .Include(t => t.WorkItem) .ThenInclude(wi => wi!.WorkArea) .ThenInclude(wa => wa!.Floor) .ThenInclude(f => f!.Building) .Include(t => t.WorkItem) .ThenInclude(wi => wi!.WorkCategoryMaster) .Where(t => t.WorkItem != null && t.WorkItem.WorkArea != null && t.WorkItem.WorkArea.Floor != null && t.WorkItem.WorkArea.Floor.Building != null && t.WorkItem.WorkArea.Floor.Building.ProjectId != projectId && t.TenantId == tenantId); // Step 4: Extract filter values var buildingIds = imageFilter?.BuildingIds; var floorIds = imageFilter?.FloorIds; var workAreaIds = imageFilter?.WorkAreaIds; var activityIds = imageFilter?.ActivityIds; var workCategoryIds = imageFilter?.WorkCategoryIds; var startDate = imageFilter?.StartDate; var endDate = imageFilter?.EndDate; var uploadedByIds = imageFilter?.UploadedByIds; var serviceIds = imageFilter?.ServiceIds; if (buildingIds?.Any() ?? false) { taskQuery = taskQuery .Where(t => t.WorkItem != null && t.WorkItem.WorkArea != null && t.WorkItem.WorkArea.Floor != null && buildingIds.Contains(t.WorkItem.WorkArea.Floor.BuildingId)); } if (floorIds?.Any() ?? false) { taskQuery = taskQuery .Where(t => t.WorkItem != null && t.WorkItem.WorkArea != null && floorIds.Contains(t.WorkItem.WorkArea.FloorId)); } if (workAreaIds?.Any() ?? false) { taskQuery = taskQuery .Where(t => t.WorkItem != null && workAreaIds.Contains(t.WorkItem.WorkAreaId)); } if (activityIds?.Any() == true) { taskQuery = taskQuery .Where(t => t.WorkItem != null && activityIds.Contains(t.WorkItem.ActivityId)); } if (workCategoryIds?.Any() == true) { taskQuery = taskQuery .Where(t => t.WorkItem != null && t.WorkItem.WorkCategoryId.HasValue && workCategoryIds.Contains(t.WorkItem.WorkCategoryId.Value)); } if (serviceIds?.Any() ?? false) { taskQuery = taskQuery.Where(t => t.WorkItem != null && t.WorkItem.ActivityMaster != null && t.WorkItem.ActivityMaster.ActivityGroup != null && serviceIds.Contains(t.WorkItem.ActivityMaster.ActivityGroup.ServiceId)); } // Step 6: Fetch task allocations and comments var tasks = await taskQuery.ToListAsync(); var taskIds = tasks.Select(t => t.Id).ToList(); var comments = await _context.TaskComments.Include(c => c.Employee) .Where(c => taskIds.Contains(c.TaskAllocationId)).ToListAsync(); var commentIds = comments.Select(c => c.Id).ToList(); var attachments = await _context.TaskAttachments .Where(ta => taskIds.Contains(ta.ReferenceId) || commentIds.Contains(ta.ReferenceId)).ToListAsync(); var documentIds = attachments.Select(ta => ta.DocumentId).ToList(); // Step 7: Fetch and filter documents List documents = new List(); var docQuery = _context.Documents.Include(d => d.UploadedBy) .Where(d => documentIds.Contains(d.Id) && d.TenantId == tenantId); if (startDate != null && endDate != null) { docQuery = docQuery.Where(d => d.UploadedAt.Date >= startDate.Value.Date && d.UploadedAt.Date <= endDate.Value.Date); } int totalRecords = await docQuery.GroupBy(d => d.BatchId).CountAsync(); int totalPages = (int)Math.Ceiling((double)totalRecords / pageSize); documents = await docQuery .GroupBy(d => d.BatchId) .OrderByDescending(g => g.Max(d => d.UploadedAt)) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .Select(g => new DocumentBatchDto { BatchId = g.Key, Documents = g.ToList() }) .ToListAsync(); // Step 8: Build response var documentVM = documents.Select(d => { var docIds = d.Documents?.Select(x => x.Id).ToList() ?? new List(); var refId = attachments.FirstOrDefault(ta => docIds.Contains(ta.DocumentId))?.ReferenceId; var task = tasks.FirstOrDefault(t => t.Id == refId); var comment = comments.FirstOrDefault(c => c.Id == refId); var source = task != null ? "Report" : comment != null ? "Comment" : ""; var uploadedBy = task?.ReportedBy ?? comment?.Employee; if (comment != null) { task = tasks.FirstOrDefault(t => t.Id == comment.TaskAllocationId); } if (task != null) { comment = comments.OrderBy(c => c.CommentDate).FirstOrDefault(c => c.TaskAllocationId == task.Id); } var workItem = task!.WorkItem; var workArea = task!.WorkItem!.WorkArea; var floor = task!.WorkItem!.WorkArea!.Floor; var building = task!.WorkItem!.WorkArea!.Floor!.Building; return new { BatchId = d.BatchId, Documents = d.Documents?.Select(x => new { Id = x.Id, thumbnailUrl = x.ThumbS3Key != null ? _s3Service.GeneratePreSignedUrl(x.ThumbS3Key) : (x.S3Key != null ? _s3Service.GeneratePreSignedUrl(x.S3Key) : null), Url = x.S3Key != null ? _s3Service.GeneratePreSignedUrl(x.S3Key) : null, UploadedBy = x.UploadedBy?.ToBasicEmployeeVMFromEmployee() ?? uploadedBy?.ToBasicEmployeeVMFromEmployee(), UploadedAt = x.UploadedAt, }).ToList(), Source = source, ProjectId = projectId, BuildingId = building?.Id, BuildingName = building?.Name, FloorIds = floor?.Id, FloorName = floor?.FloorName, WorkAreaId = workArea?.Id, WorkAreaName = workArea?.AreaName, TaskId = task?.Id, ActivityId = workItem?.ActivityMaster?.Id, ActivityName = workItem?.ActivityMaster?.ActivityName, WorkCategoryId = workItem?.WorkCategoryMaster?.Id, WorkCategoryName = workItem?.WorkCategoryMaster?.Name, CommentId = comment?.Id, Comment = comment?.Comment }; }).ToList(); if (uploadedByIds?.Any() == true) { documentVM = documentVM.Where(d => d.Documents != null && d.Documents.Any(x => uploadedByIds.Contains(x.UploadedBy?.Id ?? Guid.Empty))).ToList(); } var VM = new { TotalCount = totalRecords, TotalPages = totalPages, CurrentPage = pageNumber, PageSize = pageSize, Data = documentVM }; _logger.LogInfo("[GetImageList] Fetched {Count} documents for ProjectId: {ProjectId}", documentVM.Count, projectId); return Ok(ApiResponse.SuccessResponse(VM, $"{documentVM.Count} image records fetched successfully", 200)); } [HttpGet("batch/{batchId}")] public async Task GetImagesByBatch(Guid batchId) { _logger.LogInfo("GetImagesByBatch called for BatchId: {BatchId}", batchId); // Step 1: Get the logged-in employee var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 2: Retrieve all documents in the batch var documents = await _context.Documents .Include(d => d.UploadedBy) .Where(d => d.BatchId == batchId) .ToListAsync(); if (!documents.Any()) { _logger.LogWarning("No documents found for BatchId: {BatchId}", batchId); return NotFound(ApiResponse.ErrorResponse("No images found", "No images associated with this batch", 404)); } var documentIds = documents.Select(d => d.Id).ToList(); // Step 3: Get task/comment reference IDs linked to these documents var referenceIds = await _context.TaskAttachments .Where(ta => documentIds.Contains(ta.DocumentId)) .Select(ta => ta.ReferenceId) .Distinct() .ToListAsync(); // Step 4: Try to identify the source of the attachment (task or comment) var task = await _context.TaskAllocations .Include(t => t.ReportedBy) .FirstOrDefaultAsync(t => referenceIds.Contains(t.Id)); TaskComment? comment = null; WorkItem? workItem = null; Employee? uploadedBy = null; string source = ""; if (task != null) { uploadedBy = task.ReportedBy; workItem = await _context.WorkItems .Include(wi => wi.ActivityMaster) .Include(wi => wi.WorkCategoryMaster) .FirstOrDefaultAsync(wi => wi.Id == task.WorkItemId); source = "Report"; } else { comment = await _context.TaskComments .Include(tc => tc.TaskAllocation) .Include(tc => tc.Employee) .FirstOrDefaultAsync(tc => referenceIds.Contains(tc.Id)); var workItemId = comment?.TaskAllocation?.WorkItemId; uploadedBy = comment?.Employee; workItem = await _context.WorkItems .Include(wi => wi.ActivityMaster) .Include(wi => wi.WorkCategoryMaster) .FirstOrDefaultAsync(wi => wi.Id == workItemId); source = "Comment"; } // Step 5: Traverse up to building level var workAreaId = workItem?.WorkAreaId; var workArea = await _context.WorkAreas .Include(wa => wa.Floor) .FirstOrDefaultAsync(wa => wa.Id == workAreaId); var buildingId = workArea?.Floor?.BuildingId; var building = await _context.Buildings .FirstOrDefaultAsync(b => b.Id == buildingId); // Step 6: Construct the response var response = new { BatchId = batchId, Documents = documents?.Select(x => new { Id = x.Id, thumbnailUrl = x.ThumbS3Key != null ? _s3Service.GeneratePreSignedUrl(x.ThumbS3Key) : (x.S3Key != null ? _s3Service.GeneratePreSignedUrl(x.S3Key) : null), Url = x.S3Key != null ? _s3Service.GeneratePreSignedUrl(x.S3Key) : null, UploadedBy = x.UploadedBy?.ToBasicEmployeeVMFromEmployee() ?? uploadedBy?.ToBasicEmployeeVMFromEmployee(), UploadedAt = x.UploadedAt, }).ToList(), Source = source, ProjectId = building?.ProjectId, BuildingId = building?.Id, BuildingName = building?.Name, FloorIds = workArea?.Floor?.Id, FloorName = workArea?.Floor?.FloorName, WorkAreaId = workArea?.Id, WorkAreaName = workArea?.AreaName, TaskId = task?.Id, ActivityId = workItem?.ActivityMaster?.Id, ActivityName = workItem?.ActivityMaster?.ActivityName, WorkCategoryId = workItem?.WorkCategoryMaster?.Id, WorkCategoryName = workItem?.WorkCategoryMaster?.Name, CommentId = comment?.Id, Comment = comment?.Comment }; _logger.LogInfo("Fetched {Count} image(s) for BatchId: {BatchId}", response.Documents?.Count ?? 0, batchId); return Ok(ApiResponse.SuccessResponse(response, "Images for provided batchId fetched successfully", 200)); } [HttpGet("filter/{projectId}")] public async Task GetFilterObjectAsync(Guid projectId, CancellationToken ct) { _logger.LogInfo("GetFilterObject started for ProjectId {ProjectId}", projectId); // start log [memory:1] // Validate input early if (projectId == Guid.Empty) { Log.Warning("GetFilterObject received empty ProjectId"); // input validation [memory:1] return BadRequest(ApiResponse.ErrorResponse("Invalid project id", 400)); } // Load only what is needed for attachments; project scoping will be enforced downstream // NOTE: Select only the columns required to reduce payload var taskAttachments = await _context.TaskAttachments .AsNoTracking() .Select(ta => new { ta.ReferenceId, ta.DocumentId }) .ToListAsync(ct); // I/O bound work [memory:10] if (taskAttachments.Count == 0) { Log.Information("No task attachments found for ProjectId {ProjectId}", projectId); // early exit [memory:1] var emptyResponse = new { Buildings = Array.Empty(), Floors = Array.Empty(), WorkAreas = Array.Empty(), WorkCategories = Array.Empty(), Activities = Array.Empty(), UploadedBys = Array.Empty(), Services = Array.Empty() }; return Ok(ApiResponse.SuccessResponse(emptyResponse, "No data found", 200)); } // HashSets for O(1) membership tests and to dedupe upfront var referenceIds = new HashSet(taskAttachments.Select(x => x.ReferenceId)); var documentIds = new HashSet(taskAttachments.Select(x => x.DocumentId)); _logger.LogDebug("Collected {ReferenceCount} referenceIds and {DocumentCount} documentIds", referenceIds.Count, documentIds.Count); // metrics [memory:1] // Load comments for the references under this tenant var comments = await _context.TaskComments .AsNoTracking() .Where(tc => referenceIds.Contains(tc.Id) && tc.TenantId == tenantId) .Select(tc => new { tc.Id, tc.TaskAllocationId }) .ToListAsync(ct); // filtered selection [memory:10] var taskIds = new HashSet(comments.Select(c => c.TaskAllocationId)); _logger.LogDebug("Resolved {CommentCount} comments mapping to {TaskIdCount} taskIds", comments.Count, taskIds.Count); // observation [memory:1] // IMPORTANT: Correct project filter should be == not != // Include graph tailored to fields needed in final projection (avoid over-inclusion) // Avoid Task.Run for async EF (it doesn’t add value and can harm thread pool) var tasks = await _context.TaskAllocations .AsNoTracking() .Where(t => t.WorkItem != null && t.WorkItem.WorkArea != null && t.WorkItem.WorkArea.Floor != null && t.WorkItem.WorkArea.Floor.Building != null && t.WorkItem.WorkArea.Floor.Building.ProjectId == projectId && // fixed filter (taskIds.Contains(t.Id) || referenceIds.Contains(t.Id))) .Include(t => t.WorkItem!) .ThenInclude(wi => wi.ActivityMaster!) .ThenInclude(a => a.ActivityGroup!) .ThenInclude(ag => ag.Service) .Include(t => t.WorkItem!) .ThenInclude(wi => wi.WorkArea!) .ThenInclude(wa => wa.Floor!) .ThenInclude(f => f.Building) .Include(t => t.WorkItem!) .ThenInclude(wi => wi.WorkCategoryMaster) // Only select fields used later to reduce tracked object size in memory .Select(t => new { TaskId = t.Id, Building = new { Id = t.WorkItem!.WorkArea!.Floor!.Building!.Id, Name = t.WorkItem.WorkArea.Floor.Building.Name }, Floor = new { Id = t.WorkItem.WorkArea.Floor.Id, Name = t.WorkItem.WorkArea.Floor.FloorName }, WorkArea = new { Id = t.WorkItem.WorkArea.Id, Name = t.WorkItem.WorkArea.AreaName }, Activity = t.WorkItem.ActivityMaster == null ? null : new { Id = t.WorkItem.ActivityMaster.Id, Name = t.WorkItem.ActivityMaster.ActivityName }, WorkCategory = t.WorkItem.WorkCategoryMaster == null ? null : new { Id = t.WorkItem.WorkCategoryMaster.Id, Name = t.WorkItem.WorkCategoryMaster.Name }, Service = t.WorkItem.ActivityMaster!.ActivityGroup!.Service == null ? null : new { Id = t.WorkItem.ActivityMaster.ActivityGroup.Service.Id, Name = t.WorkItem.ActivityMaster.ActivityGroup.Service.Name } }) .ToListAsync(ct); // optimized projection [memory:10] _logger.LogDebug("Fetched {TaskCount} tasks after filtering and projection", tasks.Count); // metrics [memory:1] // Documents query in parallel with tasks is okay; here we’ve already awaited tasks, but both can run together if needed. // Only fetch uploader fields needed var documents = await _context.Documents .AsNoTracking() .Where(d => documentIds.Contains(d.Id)) .Select(d => new { d.Id, UploadedBy = d.UploadedBy == null ? null : new { d.UploadedBy.Id, d.UploadedBy.FirstName, d.UploadedBy.LastName } }) .ToListAsync(ct); // minimal shape [memory:10] _logger.LogDebug("Fetched {DocumentCount} documents for UploadedBy resolution", documents.Count); // metrics [memory:1] // Distinct projections via HashSet to avoid custom equality or anonymous Distinct pitfalls static List DistinctBy(IEnumerable source, Func keySelector) => source.GroupBy(keySelector).Select(g => g.First()).ToList(); var buildings = DistinctBy(tasks.Select(t => t.Building), b => b.Id); var floors = DistinctBy(tasks.Select(t => t.Floor), f => f.Id); var workAreas = DistinctBy(tasks.Select(t => t.WorkArea), wa => wa.Id); var activities = DistinctBy(tasks.Where(t => t.Activity != null).Select(t => t.Activity!), a => a.Id); var workCategories = DistinctBy(tasks.Where(t => t.WorkCategory != null).Select(t => t.WorkCategory!), wc => wc.Id); var services = DistinctBy(tasks.Where(t => t.Service != null).Select(t => t.Service!), s => s.Id); var uploadedBys = DistinctBy( documents.Where(d => d.UploadedBy != null) .Select(d => new { Id = d.UploadedBy!.Id, Name = string.Join(' ', new[] { d.UploadedBy!.FirstName, d.UploadedBy!.LastName }.Where(x => !string.IsNullOrWhiteSpace(x))) }), u => u.Id); var response = new { Buildings = buildings, Floors = floors, WorkAreas = workAreas, WorkCategories = workCategories, Activities = activities, UploadedBys = uploadedBys, Services = services }; _logger.LogInfo("GetFilterObject succeeded for ProjectId {ProjectId}. Buildings={Buildings}, Floors={Floors}, WorkAreas={WorkAreas}, Activities={Activities}, WorkCategories={WorkCategories}, Services={Services}, UploadedBys={UploadedBys}", projectId, buildings.Count, floors.Count, workAreas.Count, activities.Count, workCategories.Count, services.Count, uploadedBys.Count); // success log [memory:1] return Ok(ApiResponse.SuccessResponse(response, "Filter object for image gallery fetched successfully", 200)); } [HttpGet("{documentId}")] public async Task GetImage(Guid documentId) { // Log the start of the image fetch process _logger.LogInfo("GetImage called for DocumentId: {DocumentId}", documentId); // Step 1: Get the currently logged-in employee (for future use like permission checks or auditing) var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 2: Fetch the document from the database based on the provided ID var document = await _context.Documents.FirstOrDefaultAsync(d => d.Id == documentId); // Step 3: If document doesn't exist, return a 400 Bad Request response if (document == null) { _logger.LogWarning("Document not found for DocumentId: {DocumentId}", documentId); return BadRequest(ApiResponse.ErrorResponse("Document not found", "Document not found", 400)); } // Step 4: Generate pre-signed URLs for thumbnail and full image (if keys exist) string? thumbnailUrl = document.ThumbS3Key != null ? _s3Service.GeneratePreSignedUrl(document.ThumbS3Key) : null; string? imageUrl = document.S3Key != null ? _s3Service.GeneratePreSignedUrl(document.S3Key) : null; // Step 5: Prepare the response object var response = new { ThumbnailUrl = thumbnailUrl, ImageUrl = imageUrl }; // Step 6: Log successful fetch and return the result _logger.LogInfo("Image fetched successfully for DocumentId: {DocumentId}", documentId); return Ok(ApiResponse.SuccessResponse(response, "Image fetched successfully", 200)); } } }