diff --git a/Marco.Pms.Services/Controllers/ImageController.cs b/Marco.Pms.Services/Controllers/ImageController.cs index 46cb5fc..1c390ac 100644 --- a/Marco.Pms.Services/Controllers/ImageController.cs +++ b/Marco.Pms.Services/Controllers/ImageController.cs @@ -13,6 +13,7 @@ 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 @@ -22,20 +23,27 @@ namespace Marco.Pms.Services.Controllers [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(ApplicationDbContext context, S3UploadService s3Service, UserHelper userHelper, ILoggingService logger, PermissionServices permission) + public ImageController(IDbContextFactory dbContextFactory, + ApplicationDbContext context, + S3UploadService s3Service, + UserHelper userHelper, + ILoggingService logger, + PermissionServices permission) { - _context = context; - _s3Service = s3Service; - _userHelper = userHelper; - _logger = logger; + _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(); - _permission = permission; } [HttpGet("images/{projectId}")] @@ -382,6 +390,150 @@ namespace Marco.Pms.Services.Controllers 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) {