From 3b4b09783b09df60ee6c56c996d75d3c3cfc894e Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Sat, 19 Jul 2025 20:32:06 +0530 Subject: [PATCH] Completed the get expenses list API with optimized code --- Marco.Pms.Model/Utilities/ExpensesFilter.cs | 12 + .../Controllers/ExpenseController.cs | 322 ++++++++++++++++-- 2 files changed, 301 insertions(+), 33 deletions(-) create mode 100644 Marco.Pms.Model/Utilities/ExpensesFilter.cs diff --git a/Marco.Pms.Model/Utilities/ExpensesFilter.cs b/Marco.Pms.Model/Utilities/ExpensesFilter.cs new file mode 100644 index 0000000..7a0c397 --- /dev/null +++ b/Marco.Pms.Model/Utilities/ExpensesFilter.cs @@ -0,0 +1,12 @@ +namespace Marco.Pms.Model.Utilities +{ + public class ExpensesFilter + { + public List? ProjectIds { get; set; } + public List? StatusIds { get; set; } + public List? CreatedByIds { get; set; } + public List? PaidById { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + } +} diff --git a/Marco.Pms.Services/Controllers/ExpenseController.cs b/Marco.Pms.Services/Controllers/ExpenseController.cs index ccde41d..23aae3f 100644 --- a/Marco.Pms.Services/Controllers/ExpenseController.cs +++ b/Marco.Pms.Services/Controllers/ExpenseController.cs @@ -12,6 +12,7 @@ using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Text.Json; using Document = Marco.Pms.Model.DocumentManager.Document; // For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860 @@ -48,7 +49,7 @@ namespace Marco.Pms.Services.Controllers } [HttpGet("list")] - public async Task GetExpensesList() + public async Task GetExpensesList1(string? filter, int pageSize = 20, int pageNumber = 1) { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var loggedInEmployeeId = loggedInEmployee.Id; @@ -62,42 +63,78 @@ namespace Marco.Pms.Services.Controllers .Include(e => e.PaymentMode) .Include(e => e.Status) .Include(e => e.CreatedBy) - .Where(e => e.TenantId == tenantId); + .Where(e => e.TenantId == tenantId) + .OrderByDescending(e => e.CreatedAt) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize); var HasViewAllPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployeeId); var HasViewSelfPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId); - if (HasViewAllPermission) + if (HasViewSelfPermission) { - expensesList = await expensesListQuery.ToListAsync(); + expensesListQuery = expensesListQuery.Where(e => e.CreatedById == loggedInEmployeeId); } - else if (HasViewSelfPermission) + else if (!HasViewAllPermission) { - expensesList = await expensesListQuery.Where(e => e.CreatedById == loggedInEmployeeId).ToListAsync(); - } - - if (expensesList == null) - { - _logger.LogInfo("No Expense found for employee {EmployeeId}", loggedInEmployeeId); + _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expanses list.", loggedInEmployeeId); return Ok(ApiResponse.SuccessResponse(new List(), "No Expense found for current user", 200)); } - //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); - // } - //} + ExpensesFilter? expenesFilter = null; + if (!string.IsNullOrWhiteSpace(filter)) + { + try + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + expenesFilter = JsonSerializer.Deserialize(filter, options); + } + catch (Exception ex) + { + _logger.LogError(ex, "[GetExpensesList] Failed to parse filter came from website or mobile"); + try + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + string unescapedJsonString = JsonSerializer.Deserialize(filter, options) ?? ""; + expenesFilter = JsonSerializer.Deserialize(unescapedJsonString, options); + } + catch (Exception ex1) + { + _logger.LogError(ex1, "[GetExpensesList] Failed to parse filter came from postman"); + } + } + } + + var projectIds = expenesFilter?.ProjectIds; + var filterStatusIds = expenesFilter?.StatusIds; + var createdByIds = expenesFilter?.CreatedByIds; + var paidById = expenesFilter?.PaidById; + var startDate = expenesFilter?.StartDate; + var endDate = expenesFilter?.EndDate; + + if (startDate != null && endDate != null) + { + expensesListQuery = expensesListQuery.Where(e => e.CreatedAt.Date >= startDate && e.CreatedAt.Date <= endDate); + } + else if (projectIds != null && projectIds.Any()) + { + expensesListQuery = expensesListQuery.Where(e => projectIds.Contains(e.ProjectId)); + } + else if (filterStatusIds != null && filterStatusIds.Any()) + { + expensesListQuery = expensesListQuery.Where(e => filterStatusIds.Contains(e.StatusId)); + } + else if (createdByIds != null && createdByIds.Any() && HasViewAllPermission) + { + expensesListQuery = expensesListQuery.Where(e => createdByIds.Contains(e.CreatedById)); + } + else if (paidById != null && paidById.Any()) + { + expensesListQuery = expensesListQuery.Where(e => paidById.Contains(e.PaidById)); + } + + expensesList = await expensesListQuery.ToListAsync(); var response = _mapper.Map>(expensesList); @@ -117,6 +154,225 @@ namespace Marco.Pms.Services.Controllers return StatusCode(200, ApiResponse.SuccessResponse(response, $"{response.Count} records of expenses for you fetched successfully", 200)); } + /// + /// Retrieves a paginated list of expenses based on user permissions and optional filters. + /// + /// A URL-encoded JSON string containing filter criteria. See . + /// The number of records to return per page. + /// The page number to retrieve. + /// A paginated list of expenses. + [HttpGet] // Assuming this is a GET endpoint + public async Task GetExpensesList(string? filter, int pageSize = 20, int pageNumber = 1) + { + try + { + _logger.LogInfo( + "Attempting to fetch expenses list for PageNumber: {PageNumber}, PageSize: {PageSize} with Filter: {Filter}", + pageNumber, pageSize, filter ?? ""); + + // 1. --- Get User and Permissions --- + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + if (loggedInEmployee == null) + { + // This is an authentication/authorization issue. The user should be logged in. + _logger.LogWarning("Could not find an employee for the current logged-in user."); + return Unauthorized(ApiResponse.ErrorResponse("User not found or not authenticated.", 401)); + } + Guid loggedInEmployeeId = loggedInEmployee.Id; + + var hasViewAllPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployeeId); + var hasViewSelfPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId); + + // 2. --- Build Base Query and Apply Permissions --- + // Start with a base IQueryable. Filters will be chained onto this. + var expensesQuery = _context.Expenses + .Include(e => e.ExpensesType) + .Include(e => e.Project) + .Include(e => e.PaidBy) + .ThenInclude(e => e!.JobRole) + .Include(e => e.PaymentMode) + .Include(e => e.Status) + .Include(e => e.CreatedBy) + .Where(e => e.TenantId == tenantId); // Always filter by TenantId first. + + // Apply permission-based filtering BEFORE any other filters or pagination. + if (hasViewAllPermission) + { + // User has 'View All' permission, no initial restriction on who created the expense. + _logger.LogInfo("User {EmployeeId} has 'View All' permission.", loggedInEmployeeId); + } + else if (hasViewSelfPermission) + { + // User only has 'View Self' permission, so restrict the query to their own expenses. + _logger.LogInfo("User {EmployeeId} has 'View Self' permission. Restricting query to their expenses.", loggedInEmployeeId); + expensesQuery = expensesQuery.Where(e => e.CreatedById == loggedInEmployeeId); + } + else + { + // User has neither required permission. Deny access. + _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expenses list.", loggedInEmployeeId); + return Ok(ApiResponse.SuccessResponse(new List(), "You do not have permission to view any expenses.", 200)); + } + + // 3. --- Deserialize Filter and Apply --- + ExpensesFilter? expenseFilter = TryDeserializeFilter(filter); + + if (expenseFilter != null) + { + // CRITICAL FIX: Apply filters cumulatively using multiple `if` statements, not `if-else if`. + if (expenseFilter.StartDate.HasValue && expenseFilter.EndDate.HasValue) + { + expensesQuery = expensesQuery.Where(e => e.CreatedAt.Date >= expenseFilter.StartDate.Value.Date && e.CreatedAt.Date <= expenseFilter.EndDate.Value.Date); + } + + if (expenseFilter.ProjectIds?.Any() == true) + { + expensesQuery = expensesQuery.Where(e => expenseFilter.ProjectIds.Contains(e.ProjectId)); + } + + if (expenseFilter.StatusIds?.Any() == true) + { + expensesQuery = expensesQuery.Where(e => expenseFilter.StatusIds.Contains(e.StatusId)); + } + + if (expenseFilter.PaidById?.Any() == true) + { + expensesQuery = expensesQuery.Where(e => expenseFilter.PaidById.Contains(e.PaidById)); + } + + // Only allow filtering by 'CreatedBy' if the user has 'View All' permission. + if (expenseFilter.CreatedByIds?.Any() == true && hasViewAllPermission) + { + expensesQuery = expensesQuery.Where(e => expenseFilter.CreatedByIds.Contains(e.CreatedById)); + } + } + + // 4. --- Apply Ordering and Pagination --- + // This should be the last step before executing the query. + var paginatedQuery = expensesQuery + .OrderByDescending(e => e.CreatedAt) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize); + + // 5. --- Execute Query and Map Results --- + var expensesList = await paginatedQuery.ToListAsync(); + + if (!expensesList.Any()) + { + _logger.LogInfo("No expenses found matching the criteria for employee {EmployeeId}.", loggedInEmployeeId); + return Ok(ApiResponse.SuccessResponse(new List(), "No expenses found for the given criteria.", 200)); + } + + var response = _mapper.Map>(expensesList); + + // 6. --- Efficiently Fetch and Append 'Next Status' Information --- + var statusIds = expensesList.Select(e => e.StatusId).Distinct().ToList(); + + var statusMappings = await _context.ExpensesStatusMapping + .Include(sm => sm.NextStatus) + .Where(sm => statusIds.Contains(sm.StatusId)) + .ToListAsync(); + + // Use a Lookup for efficient O(1) mapping. This is much better than repeated `.Where()` in a loop. + var statusMapLookup = statusMappings.ToLookup(sm => sm.StatusId); + + foreach (var expense in response) + { + if (expense.Status?.Id != null && statusMapLookup.Contains(expense.Status.Id)) + { + expense.NextStatus = statusMapLookup[expense.Status.Id] + .Select(sm => _mapper.Map(sm.NextStatus)) + .ToList(); + } + else + { + expense.NextStatus = new List(); // Ensure it's never null + } + } + + // 7. --- Return Final Success Response --- + var message = $"{response.Count} expense records fetched successfully."; + _logger.LogInfo(message); + return StatusCode(200, ApiResponse.SuccessResponse(response, message, 200)); + } + catch (DbUpdateException dbEx) + { + _logger.LogError(dbEx, "Databsae Exception occured while fetching list expenses"); + return BadRequest(ApiResponse.ErrorResponse("Databsae Exception", new + { + Message = dbEx.Message, + StackTrace = dbEx.StackTrace, + Source = dbEx.Source, + innerexcption = new + { + Message = dbEx.InnerException?.Message, + StackTrace = dbEx.InnerException?.StackTrace, + Source = dbEx.InnerException?.Source, + } + }, 400)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while fetching list expenses"); + return BadRequest(ApiResponse.ErrorResponse("Error Occured", new + { + Message = ex.Message, + StackTrace = ex.StackTrace, + Source = ex.Source, + innerexcption = new + { + Message = ex.InnerException?.Message, + StackTrace = ex.InnerException?.StackTrace, + Source = ex.InnerException?.Source, + } + }, 400)); + } + } + + /// + /// Deserializes the filter string, handling multiple potential formats (e.g., direct JSON vs. escaped JSON string). + /// + /// The JSON filter string from the request. + /// An object or null if deserialization fails. + private ExpensesFilter? TryDeserializeFilter(string? filter) + { + if (string.IsNullOrWhiteSpace(filter)) + { + return null; + } + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + ExpensesFilter? 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; + } + [HttpGet("details/{id}")] public string Get(int id) { @@ -199,13 +455,13 @@ namespace Marco.Pms.Services.Controllers catch (Exception ex) { _logger.LogError(ex, "Error occured while saving image to S3"); - //return BadRequest(ApiResponse.ErrorResponse("Cannot upload attachment to S3", new - //{ - // message = ex.Message, - // innerexcption = ex.InnerException?.Message, - // stackTrace = ex.StackTrace, - // source = ex.Source - //}, 400)); + return BadRequest(ApiResponse.ErrorResponse("Cannot upload attachment to S3", new + { + message = ex.Message, + innerexcption = ex.InnerException?.Message, + stackTrace = ex.StackTrace, + source = ex.Source + }, 400)); } var document = new Document