578 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ApplicationDbContext> _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<ApplicationDbContext> 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<IActionResult> 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<object>.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<object>.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<string>(filter, options) ?? "";
//imageFilter = JsonSerializer.Deserialize<ImageFilter>(unescapedJsonString, options);
imageFilter = JsonSerializer.Deserialize<ImageFilter>(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<DocumentBatchDto> documents = new List<DocumentBatchDto>();
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<Guid>();
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<object>.SuccessResponse(VM, $"{documentVM.Count} image records fetched successfully", 200));
}
[HttpGet("batch/{batchId}")]
public async Task<IActionResult> 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<object>.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<object>.SuccessResponse(response, "Images for provided batchId fetched successfully", 200));
}
[HttpGet("filter/{projectId}")]
public async Task<IActionResult> 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<object>.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<object>(),
Floors = Array.Empty<object>(),
WorkAreas = Array.Empty<object>(),
WorkCategories = Array.Empty<object>(),
Activities = Array.Empty<object>(),
UploadedBys = Array.Empty<object>(),
Services = Array.Empty<object>()
};
return Ok(ApiResponse<object>.SuccessResponse(emptyResponse, "No data found", 200));
}
// HashSets for O(1) membership tests and to dedupe upfront
var referenceIds = new HashSet<Guid>(taskAttachments.Select(x => x.ReferenceId));
var documentIds = new HashSet<Guid>(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<Guid>(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 doesnt 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 weve 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<T> DistinctBy<T, TKey>(IEnumerable<T> source, Func<T, TKey> 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<object>.SuccessResponse(response, "Filter object for image gallery fetched successfully", 200));
}
[HttpGet("{documentId}")]
public async Task<IActionResult> 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<object>.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<object>.SuccessResponse(response, "Image fetched successfully", 200));
}
}
}