using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Activities; using Marco.Pms.Model.Dtos.Activities; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Filters; using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Hubs; using Marco.Pms.Services.Service; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; using System.Text.Json; using Document = Marco.Pms.Model.DocumentManager.Document; namespace MarcoBMS.Services.Controllers { [Route("api/[controller]")] [ApiController] [Authorize] public class TaskController : ControllerBase { private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly S3UploadService _s3Service; private readonly ILoggingService _logger; private readonly IHubContext _signalR; private readonly CacheUpdateHelper _cache; private readonly PermissionServices _permissionServices; private readonly IFirebaseService _firebase; public TaskController(ApplicationDbContext context, UserHelper userHelper, S3UploadService s3Service, ILoggingService logger, PermissionServices permissionServices, IHubContext signalR, CacheUpdateHelper cache, IFirebaseService firebase) { _context = context; _userHelper = userHelper; _s3Service = s3Service; _logger = logger; _signalR = signalR; _cache = cache; _permissionServices = permissionServices; _firebase = firebase; } private Guid GetTenantId() { return _userHelper.GetTenantId(); } [HttpPost("assign")] public async Task AssignTask([FromBody] AssignTaskDto assignTask) { // Validate the incoming model if (!ModelState.IsValid) { var errors = ModelState.Values .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); _logger.LogWarning("AssignTask failed validation: {@Errors}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } // Retrieve tenant and loggedInEmployee context var tenantId = GetTenantId(); var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Check for permission to approve tasks var hasPermission = await _permissionServices.HasPermission(PermissionsMaster.AssignAndReportProgress, loggedInEmployee.Id); if (!hasPermission) { _logger.LogWarning("Employee {EmployeeId} attempted to assign Task without permission", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse("You don't have access", "User not authorized to approve tasks", 403)); } _logger.LogInfo("Employee {EmployeeId} is assigning a new task", loggedInEmployee.Id); // Convert DTO to entity and save TaskAllocation var taskAllocation = assignTask.ToTaskAllocationFromAssignTaskDto(loggedInEmployee.Id, tenantId); _context.TaskAllocations.Add(taskAllocation); await _context.SaveChangesAsync(); await _cache.UpdatePlannedAndCompleteWorksInWorkItem(taskAllocation.WorkItemId, todaysAssigned: taskAllocation.PlannedTask); _logger.LogInfo("Task {TaskId} assigned by Employee {EmployeeId}", taskAllocation.Id, loggedInEmployee.Id); var response = taskAllocation.ToAssignTaskVMFromTaskAllocation(); // Map team members var teamMembers = new List(); if (assignTask.TaskTeam != null && assignTask.TaskTeam.Any()) { teamMembers = assignTask.TaskTeam.Select(memberId => new TaskMembers { TaskAllocationId = taskAllocation.Id, EmployeeId = memberId, TenantId = tenantId }).ToList(); _context.TaskMembers.AddRange(teamMembers); await _context.SaveChangesAsync(); _logger.LogInfo("Team members added to Task {TaskId}: {@TeamMemberIds}", taskAllocation.Id, assignTask.TaskTeam); } // Get team member details var employeeIds = teamMembers.Select(m => m.EmployeeId).ToList(); var employees = await _context.Employees .Where(e => employeeIds.Contains(e.Id)) .ToListAsync(); var team = employees.Select(e => e.ToBasicEmployeeVMFromEmployee()).ToList(); response.teamMembers = team; _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; await _firebase.SendAssignTaskMessageAsync(taskAllocation.WorkItemId, name, employeeIds, tenantId); }); return Ok(ApiResponse.SuccessResponse(response, "Task assigned successfully", 200)); } [HttpPost("report")] public async Task ReportTaskProgress([FromBody] ReportTaskDto reportTask) { if (!ModelState.IsValid) { var errors = ModelState.Values .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); _logger.LogWarning("Task report validation failed: {@Errors}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var tenantId = GetTenantId(); var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var hasPermission = await _permissionServices.HasPermission(PermissionsMaster.AssignAndReportProgress, loggedInEmployee.Id); if (!hasPermission) { _logger.LogWarning("Unauthorized task report attempt by Employee {EmployeeId} for Task {TaskId}", loggedInEmployee.Id, reportTask.Id); return StatusCode(403, ApiResponse.ErrorResponse("You don't have access", "User not authorized to report tasks", 403)); } var taskAllocation = await _context.TaskAllocations .Include(t => t.WorkItem) .ThenInclude(wi => wi!.WorkArea) .ThenInclude(wa => wa!.Floor) .ThenInclude(f => f!.Building) .FirstOrDefaultAsync(t => t.Id == reportTask.Id && t.WorkItem != null && t.WorkItem.WorkArea != null && t.WorkItem.WorkArea.Floor != null && t.WorkItem.WorkArea.Floor.Building != null); if (taskAllocation == null) { _logger.LogWarning("No task allocation found with ID {TaskId}", reportTask.Id); return BadRequest(ApiResponse.ErrorResponse("No such task has been allocated.", "No such task has been allocated.", 400)); } var checkListIds = reportTask.CheckList?.Select(c => c.Id).ToList() ?? new List(); var checkList = await _context.ActivityCheckLists .Where(c => checkListIds.Contains(c.Id)) .ToListAsync(); if (taskAllocation.WorkItem != null) { if (taskAllocation.CompletedTask > 0) taskAllocation.WorkItem.CompletedWork -= taskAllocation.CompletedTask; taskAllocation.WorkItem.CompletedWork += reportTask.CompletedTask; } taskAllocation.ParentTaskId = reportTask.ParentTaskId; taskAllocation.ReportedDate = reportTask.ReportedDate; taskAllocation.ReportedById = loggedInEmployee.Id; taskAllocation.CompletedTask = reportTask.CompletedTask; //taskAllocation.ReportedTask = reportTask.CompletedTask; var checkListMappings = new List(); var checkListVMs = new List(); if (reportTask.CheckList != null) { var activityId = taskAllocation.WorkItem?.ActivityId ?? Guid.Empty; foreach (var checkDto in reportTask.CheckList) { checkListVMs.Add(checkDto.ToCheckListVMFromReportCheckListDto(activityId)); if (checkDto.IsChecked && checkList.Any(c => c.Id == checkDto.Id)) { checkListMappings.Add(new CheckListMappings { CheckListId = checkDto.Id, TaskAllocationId = reportTask.Id }); } } _context.CheckListMappings.AddRange(checkListMappings); } var comment = reportTask.ToCommentFromReportTaskDto(tenantId, loggedInEmployee.Id); _context.TaskComments.Add(comment); int numberofImages = 0; var workAreaId = taskAllocation.WorkItem?.WorkAreaId; var workArea = await _context.WorkAreas.Include(a => a.Floor) .FirstOrDefaultAsync(a => a.Id == workAreaId) ?? new WorkArea(); var buildingId = workArea.Floor?.BuildingId; var building = await _context.Buildings .FirstOrDefaultAsync(b => b.Id == buildingId); var batchId = Guid.NewGuid(); var projectId = building?.ProjectId; if (reportTask.Images?.Any() == true) { foreach (var image in reportTask.Images) { if (string.IsNullOrEmpty(image.Base64Data)) { _logger.LogWarning("Image upload failed: Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } var base64 = image.Base64Data.Contains(',') ? image.Base64Data[(image.Base64Data.IndexOf(",") + 1)..] : image.Base64Data; var fileType = _s3Service.GetContentTypeFromBase64(base64); var fileName = _s3Service.GenerateFileName(fileType, taskAllocation.Id, "task_report"); var objectKey = $"tenant-{tenantId}/project-{projectId}/Activity/{fileName}"; await _s3Service.UploadFileAsync(base64, fileType, objectKey); var document = new Document { BatchId = batchId, UploadedById = loggedInEmployee.Id, FileName = image.FileName ?? "", ContentType = image.ContentType ?? "", S3Key = objectKey, //Base64Data = image.Base64Data, FileSize = image.FileSize, UploadedAt = DateTime.UtcNow, TenantId = tenantId }; _context.Documents.Add(document); var attachment = new TaskAttachment { DocumentId = document.Id, ReferenceId = reportTask.Id }; _context.TaskAttachments.Add(attachment); numberofImages += 1; } } await _context.SaveChangesAsync(); var selectedWorkAreaId = taskAllocation.WorkItem?.WorkAreaId ?? Guid.Empty; await _cache.UpdatePlannedAndCompleteWorksInWorkItem(taskAllocation.WorkItemId, completedWork: taskAllocation.CompletedTask); await _cache.UpdatePlannedAndCompleteWorksInBuilding(selectedWorkAreaId, completedWork: taskAllocation.CompletedTask); var response = taskAllocation.ToReportTaskVMFromTaskAllocation(); var comments = await _context.TaskComments .Where(c => c.TaskAllocationId == taskAllocation.Id) .ToListAsync(); response.Comments = comments.Select(c => c.ToCommentVMFromTaskComment()).ToList(); response.checkList = checkListVMs; var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Task_Report", NumberOfImages = numberofImages, ProjectId = projectId }; await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); _logger.LogInfo("Task {TaskId} reported successfully by Employee {EmployeeId}", taskAllocation.Id, loggedInEmployee.Id); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; await _firebase.SendReportTaskMessageAsync(taskAllocation.Id, name, tenantId); }); return Ok(ApiResponse.SuccessResponse(response, "Task reported successfully", 200)); } [HttpPost("comment")] public async Task AddCommentForTask([FromBody] CreateCommentDto createComment) { _logger.LogInfo("AddCommentForTask called for TaskAllocationId: {TaskId}", createComment.TaskAllocationId); var tenantId = GetTenantId(); var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Validate Task Allocation and associated WorkItem var taskAllocation = await _context.TaskAllocations .Include(t => t.WorkItem) .FirstOrDefaultAsync(t => t.Id == createComment.TaskAllocationId); if (taskAllocation == null || taskAllocation.WorkItem == null) { _logger.LogWarning("Invalid task allocation or work item not found."); return BadRequest(ApiResponse.ErrorResponse("No such task has been allocated.", "No such task has been allocated.", 400)); } // Fetch WorkArea and Building (if available) var workArea = await _context.WorkAreas .Include(a => a.Floor) .FirstOrDefaultAsync(a => a.Id == taskAllocation.WorkItem.WorkAreaId) ?? new WorkArea(); var buildingId = workArea.Floor?.BuildingId ?? Guid.Empty; var building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == buildingId); var projectId = building?.ProjectId; // Save comment var comment = createComment.ToCommentFromCommentDto(tenantId, loggedInEmployee.Id); _context.TaskComments.Add(comment); await _context.SaveChangesAsync(); _logger.LogInfo("Comment saved with Id: {CommentId}", comment.Id); // Process image uploads var images = createComment.Images; var batchId = Guid.NewGuid(); int numberofImages = 0; if (images != null && images.Any()) { foreach (var image in images) { if (string.IsNullOrWhiteSpace(image.Base64Data)) { _logger.LogWarning("Missing Base64 data in one of the images."); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } // Clean base64 string var base64 = image.Base64Data.Contains(",") ? image.Base64Data.Substring(image.Base64Data.IndexOf(",") + 1) : image.Base64Data; var fileType = _s3Service.GetContentTypeFromBase64(base64); var fileName = _s3Service.GenerateFileName(fileType, comment.Id, "task_comment"); var objectKey = $"tenant-{tenantId}/project-{projectId}/Activity/{fileName}"; await _s3Service.UploadFileAsync(base64, fileType, objectKey); _logger.LogInfo("Image uploaded to S3 with key: {ObjectKey}", objectKey); var document = new Document { BatchId = batchId, UploadedById = loggedInEmployee.Id, FileName = image.FileName ?? string.Empty, ContentType = image.ContentType ?? fileType, S3Key = objectKey, //Base64Data = image.Base64Data, FileSize = image.FileSize, UploadedAt = DateTime.UtcNow, TenantId = tenantId }; _context.Documents.Add(document); var attachment = new TaskAttachment { DocumentId = document.Id, ReferenceId = comment.Id }; _context.TaskAttachments.Add(attachment); numberofImages += 1; } await _context.SaveChangesAsync(); _logger.LogInfo("Documents and attachments saved for commentId: {CommentId}", comment.Id); } // Convert to view model and return response var response = comment.ToCommentVMFromTaskComment(); _logger.LogInfo("Returning response for commentId: {CommentId}", comment.Id); var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Task_Comment", NumberOfImages = numberofImages, ProjectId = projectId }; await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; await _firebase.SendTaskCommentMessageAsync(taskAllocation.Id, name, tenantId); }); return Ok(ApiResponse.SuccessResponse(response, "Comment saved successfully", 200)); } [HttpGet("list")] public async Task GetTasksList([FromQuery] Guid projectId, [FromQuery] string? filter, [FromQuery] Guid? serviceId, [FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20) { _logger.LogInfo("GetTasksList called for projectId: {ProjectId}", projectId); Guid tenantId = GetTenantId(); // 1. Get task allocations in the specified date range var taskAllocationQuery = _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!.WorkCategoryMaster) .Include(t => t.WorkItem) .ThenInclude(wi => wi!.WorkArea) .ThenInclude(wa => wa!.Floor) .ThenInclude(f => f!.Building) .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); var taskFilter = TryDeserializeFilter(filter); if (taskFilter != null) { if (taskFilter.BuildingIds?.Any() ?? false) { taskAllocationQuery = taskAllocationQuery.Where(t => t.WorkItem != null && t.WorkItem.WorkArea != null && t.WorkItem.WorkArea.Floor != null && taskFilter.BuildingIds.Contains(t.WorkItem.WorkArea.Floor.BuildingId)); } if (taskFilter.FloorIds?.Any() ?? false) { taskAllocationQuery = taskAllocationQuery.Where(t => t.WorkItem != null && t.WorkItem.WorkArea != null && taskFilter.FloorIds.Contains(t.WorkItem.WorkArea.FloorId)); } if (taskFilter.ActivityIds?.Any() ?? false) { taskAllocationQuery = taskAllocationQuery.Where(t => t.WorkItem != null && taskFilter.ActivityIds.Contains(t.WorkItem.ActivityId)); } if (taskFilter.ServiceIds?.Any() ?? false) { taskAllocationQuery = taskAllocationQuery.Where(t => t.WorkItem != null && t.WorkItem.ActivityMaster != null && t.WorkItem.ActivityMaster.ActivityGroup != null && taskFilter.ServiceIds.Contains(t.WorkItem.ActivityMaster.ActivityGroup.ServiceId)); } if (taskFilter.dateFrom.HasValue && taskFilter.dateTo.HasValue) { taskAllocationQuery = taskAllocationQuery.Where(t => t.AssignmentDate.Date >= taskFilter.dateFrom.Value.Date && t.AssignmentDate.Date <= taskFilter.dateTo.Value.Date); } } if (serviceId.HasValue) { taskAllocationQuery = taskAllocationQuery.Where(t => t.WorkItem != null && t.WorkItem.ActivityMaster != null && t.WorkItem.ActivityMaster.ActivityGroup != null && t.WorkItem.ActivityMaster.ActivityGroup.ServiceId == serviceId); } int totalRecords = await taskAllocationQuery.CountAsync(); int totalPages = (int)Math.Ceiling((double)totalRecords / pageSize); var taskAllocations = await taskAllocationQuery .OrderByDescending(t => t.AssignmentDate) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); var taskIds = taskAllocations.Select(t => t.Id).ToList(); // 2. Load team members _logger.LogInfo("Loading task members and related employee data."); var teamMembers = await _context.TaskMembers .Include(t => t.Employee) .Where(t => taskIds.Contains(t.TaskAllocationId)) .ToListAsync(); // 3. Load task comments _logger.LogInfo("Fetching comments and attachments."); var allComments = await _context.TaskComments .Include(c => c.Employee) .Where(c => taskIds.Contains(c.TaskAllocationId)) .ToListAsync(); var commentIds = allComments.Select(c => c.Id).ToList(); // 4. Load all attachments (task and comment) var attachments = await _context.TaskAttachments .Where(t => taskIds.Contains(t.ReferenceId) || commentIds.Contains(t.ReferenceId)) .ToListAsync(); var documentIds = attachments.Select(t => t.DocumentId).ToList(); // 5. Load actual documents from attachment references var documents = await _context.Documents .Where(d => documentIds.Contains(d.Id)) .ToListAsync(); var tasks = new List(); _logger.LogInfo("Constructing task response data."); foreach (var taskAllocation in taskAllocations) { var response = taskAllocation.ToListTaskVMFromTaskAllocation(); // Attach documents to the main task var taskDocIds = attachments .Where(a => a.ReferenceId == taskAllocation.Id) .Select(a => a.DocumentId) .ToList(); var taskDocs = documents .Where(d => taskDocIds.Contains(d.Id)) .ToList(); response.ReportedPreSignedUrls = taskDocs .Select(d => _s3Service.GeneratePreSignedUrl(d.S3Key)) .ToList(); // Add team members var taskMemberEntries = teamMembers .Where(m => m.TaskAllocationId == taskAllocation.Id) .ToList(); response.teamMembers = taskMemberEntries .Select(m => m.Employee) .Where(e => e != null) .Select(e => e!.ToBasicEmployeeVMFromEmployee()) .ToList(); // Add comments with attachments var commentVMs = new List(); var taskComments = allComments .Where(c => c.TaskAllocationId == taskAllocation.Id) .ToList(); foreach (var comment in taskComments) { var commentDocIds = attachments .Where(a => a.ReferenceId == comment.Id) .Select(a => a.DocumentId) .ToList(); var commentDocs = documents .Where(d => commentDocIds.Contains(d.Id)) .ToList(); var commentVm = comment.ToCommentVMFromTaskComment(); commentVm.PreSignedUrls = commentDocs .Select(d => _s3Service.GeneratePreSignedUrl(d.S3Key)) .ToList(); commentVMs.Add(commentVm); } response.comments = commentVMs; // Checklists var activityId = taskAllocation.WorkItem?.ActivityId ?? Guid.Empty; var checkLists = await _context.ActivityCheckLists .Where(c => c.ActivityId == activityId) .ToListAsync(); var checkListMappings = await _context.CheckListMappings .Where(c => c.TaskAllocationId == taskAllocation.Id) .ToListAsync(); response.CheckList = checkLists.Select(check => { var isChecked = checkListMappings.Any(m => m.CheckListId == check.Id); return check.ToCheckListVMFromActivityCheckList(check.ActivityId, isChecked); }).ToList(); tasks.Add(response); } var VM = new { TotalCount = totalRecords, TotalPages = totalPages, CurrentPage = pageNumber, PageSize = pageSize, Data = tasks }; _logger.LogInfo("Task list constructed successfully. Returning {Count} tasks.", tasks.Count); return Ok(ApiResponse.SuccessResponse(VM, "Success", 200)); } [HttpGet("get/{taskId}")] public async Task GetTask(Guid taskId) { _logger.LogInfo("GetTask called with taskId: {TaskId}", taskId); // Validate input if (taskId == Guid.Empty) { _logger.LogWarning("Invalid taskId provided."); return BadRequest(ApiResponse.ErrorResponse("Invalid data", "Invalid data", 400)); } // Fetch Task Allocation with required related data var taskAllocation = await _context.TaskAllocations .Include(t => t.Tenant) .Include(t => t.Employee) .Include(t => t.ReportedBy) .Include(t => t.ApprovedBy) .Include(t => t.WorkItem) .Include(t => t.WorkStatus) .FirstOrDefaultAsync(t => t.Id == taskId); if (taskAllocation == null) { _logger.LogWarning("Task not found for taskId: {TaskId}", taskId); return NotFound(ApiResponse.ErrorResponse("Task Not Found", "Task not found", 404)); } if (taskAllocation.Employee == null || taskAllocation.Tenant == null) { _logger.LogWarning("Task found but missing Employee or Tenant data for taskId: {TaskId}", taskId); return NotFound(ApiResponse.ErrorResponse("Task Not Found", "Task not found", 404)); } _logger.LogInfo("Task allocation found. Preparing response."); var taskVM = taskAllocation.TaskAllocationToTaskVM(); // Fetch comments and attachments _logger.LogInfo("Fetching comments and attachments for taskId: {TaskId}", taskId); var comments = await _context.TaskComments .Where(c => c.TaskAllocationId == taskId) .Include(c => c.Employee) .ToListAsync(); var commentIds = comments.Select(c => c.Id).ToList(); var taskAttachments = await _context.TaskAttachments .Where(t => t.ReferenceId == taskId || commentIds.Contains(t.ReferenceId)) .ToListAsync(); var documentIds = taskAttachments.Select(t => t.DocumentId).Distinct().ToList(); var documents = await _context.Documents .Where(d => documentIds.Contains(d.Id)) .ToListAsync(); // Fetch team members _logger.LogInfo("Fetching team members for taskId: {TaskId}", taskId); var team = await _context.TaskMembers .Where(m => m.TaskAllocationId == taskId) .Include(m => m.Employee) .ToListAsync(); var teamMembers = team .Where(m => m.Employee != null) .Select(m => m.Employee!.ToBasicEmployeeVMFromEmployee()) .ToList(); taskVM.TeamMembers = teamMembers; // Attach documents to the main task _logger.LogInfo("Generating presigned URLs for task documents."); var taskDocumentIds = taskAttachments .Where(t => t.ReferenceId == taskId) .Select(t => t.DocumentId) .ToList(); var taskDocuments = documents .Where(d => taskDocumentIds.Contains(d.Id)) .ToList(); taskVM.PreSignedUrls = taskDocuments .Select(d => _s3Service.GeneratePreSignedUrl(d.S3Key)) .ToList(); // Construct CommentVM list with document URLs _logger.LogInfo("Preparing comment response data."); var commentVMs = comments.Select(comment => { var commentDocIds = taskAttachments .Where(t => t.ReferenceId == comment.Id) .Select(t => t.DocumentId) .ToList(); var commentDocs = documents .Where(d => commentDocIds.Contains(d.Id)) .ToList(); var commentVM = comment.ToCommentVMFromTaskComment(); commentVM.PreSignedUrls = commentDocs .Select(d => _s3Service.GeneratePreSignedUrl(d.S3Key)) .ToList(); return commentVM; }).ToList(); taskVM.Comments = commentVMs; _logger.LogInfo("Task details prepared successfully for taskId: {TaskId}", taskId); return Ok(ApiResponse.SuccessResponse(taskVM, "Success", 200)); } [HttpGet("filter/{projectId}")] public async Task GetTaskFilterObject(Guid projectId) { // Get the current tenant from claims/context Guid tenantId = GetTenantId(); // Log API invocation with the project and tenant for traceability _logger.LogInfo("Fetching filter objects for ProjectId={ProjectId}, TenantId={TenantId}", projectId, tenantId); try { // AsNoTracking for improved performance—no intention to update these records // Only fetch & project properties actually required (DTO projection) var tasks = await _context.TaskAllocations .Include(t => t.WorkItem) .ThenInclude(wi => wi!.WorkArea) .ThenInclude(wa => wa!.Floor) .ThenInclude(f => f!.Building) .Include(t => t.WorkItem) .ThenInclude(wi => wi!.ActivityMaster) .ThenInclude(a => a!.ActivityGroup) .ThenInclude(ag => ag!.Service) .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).ToListAsync(); // Distinct by Id (since projection doesn't guarantee uniqueness across different allocations) var buildings = tasks.Where(t => t.WorkItem != null && t.WorkItem.WorkArea != null && t.WorkItem.WorkArea.Floor != null && t.WorkItem.WorkArea.Floor.Building != null) .Select(t => t.WorkItem!.WorkArea!.Floor!.Building!) .Select(b => new { Id = b.Id, Name = b.Name }).Distinct().ToList(); var floors = tasks.Where(t => t.WorkItem != null && t.WorkItem.WorkArea != null && t.WorkItem.WorkArea.Floor != null) .Select(t => t.WorkItem!.WorkArea!.Floor!) .Select(f => new { Id = f.Id, Name = f.FloorName, BuildingId = f.BuildingId }).Distinct().ToList(); var activities = tasks.Where(t => t.WorkItem != null && t.WorkItem.ActivityMaster != null && t.WorkItem.ActivityMaster.ActivityGroup != null && t.WorkItem.ActivityMaster.ActivityGroup.Service != null) .Select(t => t.WorkItem!.ActivityMaster!) .Select(a => new { Id = a.Id, Name = a.ActivityName }).Distinct().ToList(); var services = tasks.Where(t => t.WorkItem != null && t.WorkItem.ActivityMaster != null && t.WorkItem.ActivityMaster.ActivityGroup != null && t.WorkItem.ActivityMaster.ActivityGroup.Service != null) .Select(t => t.WorkItem!.ActivityMaster!.ActivityGroup!.Service!) .Select(s => new { Id = s.Id, Name = s.Name }).Distinct().ToList(); var response = new { Buildings = buildings, Floors = floors, Activities = activities, Services = services }; _logger.LogInfo("Successfully fetched filter objects for ProjectId={ProjectId}, TenantId={TenantId}", projectId, tenantId); // Use DTO in API response for clarity and easier frontend usage return Ok(ApiResponse.SuccessResponse(response, "Filter object for task fetched successfully", 200)); } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch filter objects for ProjectId={ProjectId}, TenantId={TenantId}", projectId, tenantId); // Return a standard error result return StatusCode(500, ApiResponse.ErrorResponse("Failed to fetch filter object.", 500)); } } /// /// Approves a reported task after validation, updates status, and stores attachments/comments. /// /// DTO containing task approval details. /// IActionResult indicating success or failure. [HttpPost("approve")] public async Task ApproveTask(ApproveTaskDto approveTask) { Guid tenantId = _userHelper.GetTenantId(); var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("Employee {EmployeeId} is attempting to approve Task {TaskId}", loggedInEmployee.Id, approveTask.Id); // Fetch task allocation with work item, only if it's reported var taskAllocation = await _context.TaskAllocations .Include(t => t.WorkItem) .FirstOrDefaultAsync(t => t.Id == approveTask.Id && t.TenantId == tenantId && t.ReportedDate != null); if (taskAllocation == null) { _logger.LogWarning("Task {TaskId} not found or not reported yet for Tenant {TenantId} by Employee {EmployeeId}", approveTask.Id, tenantId, loggedInEmployee.Id); return NotFound(ApiResponse.ErrorResponse("Task not found", "Task not found", 404)); } // Check for permission to approve tasks var hasPermission = await _permissionServices.HasPermission(PermissionsMaster.ApproveTask, loggedInEmployee.Id); if (!hasPermission) { _logger.LogWarning("Employee {EmployeeId} attempted to approve Task {TaskId} without permission", loggedInEmployee.Id, approveTask.Id); return StatusCode(403, ApiResponse.ErrorResponse("You don't have access", "User not authorized to approve tasks", 403)); } // Validation: Approved task count cannot exceed completed task count if (taskAllocation.CompletedTask < approveTask.ApprovedTask) { _logger.LogWarning("Invalid approval attempt on Task {TaskId}: Approved tasks ({ApprovedTask}) > Completed tasks ({CompletedTask})", approveTask.Id, approveTask.ApprovedTask, taskAllocation.CompletedTask); return BadRequest(ApiResponse.ErrorResponse("Approved tasks cannot be greater than completed tasks", "Approved tasks cannot be greater than completed tasks", 400)); } // Update task allocation details taskAllocation.ApprovedById = loggedInEmployee.Id; taskAllocation.ApprovedDate = DateTime.UtcNow; taskAllocation.WorkStatusId = approveTask.WorkStatus; taskAllocation.ReportedTask = approveTask.ApprovedTask; // Add a comment (optional) var comment = new TaskComment { TaskAllocationId = taskAllocation.Id, CommentDate = DateTime.UtcNow, Comment = approveTask.Comment ?? string.Empty, CommentedBy = loggedInEmployee.Id, TenantId = tenantId }; _context.TaskComments.Add(comment); var workAreaId = taskAllocation.WorkItem?.WorkAreaId; var workArea = await _context.WorkAreas.Include(a => a.Floor) .FirstOrDefaultAsync(a => a.Id == workAreaId) ?? new WorkArea(); var buildingId = workArea.Floor?.BuildingId; var building = await _context.Buildings .FirstOrDefaultAsync(b => b.Id == buildingId); var projectId = building?.ProjectId; int numberofImages = 0; // Handle image attachments, if any if (approveTask.Images?.Count > 0) { var batchId = Guid.NewGuid(); foreach (var image in approveTask.Images) { if (string.IsNullOrEmpty(image.Base64Data)) { _logger.LogWarning("Image for Task {TaskId} is missing base64 data", approveTask.Id); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } var base64 = image.Base64Data.Contains(",") ? image.Base64Data[(image.Base64Data.IndexOf(",") + 1)..] : image.Base64Data; var fileType = _s3Service.GetContentTypeFromBase64(base64); var fileName = _s3Service.GenerateFileName(fileType, tenantId, "task_comment"); var objectKey = $"tenant-{tenantId}/project-{projectId}/Activity/{fileName}"; await _s3Service.UploadFileAsync(base64, fileType, objectKey); var document = new Document { BatchId = batchId, UploadedById = loggedInEmployee.Id, FileName = fileName, ContentType = image.ContentType ?? string.Empty, S3Key = objectKey, //Base64Data = image.Base64Data, FileSize = image.FileSize, UploadedAt = DateTime.UtcNow, TenantId = tenantId }; _context.Documents.Add(document); var attachment = new TaskAttachment { DocumentId = document.Id, ReferenceId = comment.Id }; _context.TaskAttachments.Add(attachment); _logger.LogInfo("Attachment uploaded for Task {TaskId}: {FileName}", approveTask.Id, fileName); numberofImages += 1; } } // Commit all changes to the database await _context.SaveChangesAsync(); var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Task_Report", NumberOfImages = numberofImages, ProjectId = projectId }; await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); _logger.LogInfo("Task {TaskId} successfully approved by Employee {EmployeeId}", approveTask.Id, loggedInEmployee.Id); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; await _firebase.SendApproveTaskMessageAsync(taskAllocation.Id, name, tenantId); }); return Ok(ApiResponse.SuccessResponse("Task has been approved", "Task has been approved", 200)); } private TaskFilter? TryDeserializeFilter(string? filter) { if (string.IsNullOrWhiteSpace(filter)) { return null; } var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; TaskFilter? expenseFilter = null; try { // First, try to deserialize directly. This is the expected case (e.g., from a web client). expenseFilter = JsonSerializer.Deserialize(filter, options); } catch (JsonException ex) { _logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter); // If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients). try { // Unescape the string first, then deserialize the result. string unescapedJsonString = JsonSerializer.Deserialize(filter, options) ?? ""; if (!string.IsNullOrWhiteSpace(unescapedJsonString)) { expenseFilter = JsonSerializer.Deserialize(unescapedJsonString, options); } } catch (JsonException ex1) { // If both attempts fail, log the final error and return null. _logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter); return null; } } return expenseFilter; } } }