using AutoMapper; using Marco.Pms.CacheHelper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.DocumentManager; using Marco.Pms.Model.Dtos.Expenses; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Expenses; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Expanses; using Marco.Pms.Model.ViewModels.Master; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; using System.Text.Json; namespace Marco.Pms.Services.Service { public class ExpensesService : IExpensesService { private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly ILoggingService _logger; private readonly S3UploadService _s3Service; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly UpdateLogHelper _updateLogHelper; private readonly IMapper _mapper; private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8"); private static readonly string Collection = "ExpensesModificationLog"; public ExpensesService( IDbContextFactory dbContextFactory, ApplicationDbContext context, IServiceScopeFactory serviceScopeFactory, UpdateLogHelper updateLogHelper, ILoggingService logger, S3UploadService s3Service, IMapper mapper) { _dbContextFactory = dbContextFactory; _context = context; _logger = logger; _serviceScopeFactory = serviceScopeFactory; _updateLogHelper = updateLogHelper; _s3Service = s3Service; _mapper = mapper; } /// /// 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. public async Task> GetExpensesListAsync(Employee loggedInEmployee, Guid tenantId, string? filter, int pageSize, int pageNumber) { try { _logger.LogInfo( "Attempting to fetch expenses list for PageNumber: {PageNumber}, PageSize: {PageSize} with Filter: {Filter}", pageNumber, pageSize, filter ?? ""); // 1. --- Get User Permissions --- 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 ApiResponse.ErrorResponse("User not found or not authenticated.", 403); } Guid loggedInEmployeeId = loggedInEmployee.Id; var hasViewSelfPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId); }); var hasViewAllPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployeeId); }); await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask); // 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 (hasViewAllPermissionTask.Result) { // User has 'View All' permission, no initial restriction on who created the expense. _logger.LogInfo("User {EmployeeId} has 'View All' permission.", loggedInEmployeeId); } else if (hasViewSelfPermissionTask.Result) { // 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 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 && hasViewAllPermissionTask.Result) { 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 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 ApiResponse.SuccessResponse(response, message, 200); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Databsae Exception occured while fetching list expenses"); return 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, } }, 500); } catch (Exception ex) { _logger.LogError(ex, "Error occured while fetching list expenses"); return 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, } }, 500); } } public string Get(int id) { return "value"; } /// /// Creates a new expense entry along with its bill attachments. /// This operation is transactional and performs validations and file uploads concurrently for optimal performance /// by leveraging async/await without unnecessary thread-pool switching via Task.Run. /// /// The data transfer object containing expense details and attachments. /// An IActionResult indicating the result of the creation operation. public async Task> CreateExpenseAsync(CreateExpensesDto dto, Employee loggedInEmployee, Guid tenantId) { _logger.LogDebug("Starting CreateExpense for Project {ProjectId}", dto.ProjectId); // The entire operation is wrapped in a transaction to ensure data consistency. await using var transaction = await _context.Database.BeginTransactionAsync(); try { // 1. Authorization & Validation: Run all I/O-bound checks concurrently using factories for safety. // PERMISSION CHECKS: Use IServiceScopeFactory for thread-safe access to scoped services. var hasUploadPermissionTask = Task.Run(async () => // Task.Run is acceptable here to create a new scope, but let's do it cleaner. { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseUpload, loggedInEmployee.Id); }); var hasProjectPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasProjectPermission(loggedInEmployee, dto.ProjectId); }); // VALIDATION CHECKS: Use IDbContextFactory for thread-safe, parallel database queries. // Each task gets its own DbContext instance. var projectTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == dto.ProjectId); }); var paidByTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Employees.AsNoTracking().FirstOrDefaultAsync(e => e.Id == dto.PaidById); }); var expenseTypeTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesTypeMaster.AsNoTracking().FirstOrDefaultAsync(et => et.Id == dto.ExpensesTypeId); }); var paymentModeTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.PaymentModeMatser.AsNoTracking().FirstOrDefaultAsync(pm => pm.Id == dto.PaymentModeId); }); var statusMappingTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesStatusMapping .Include(s => s.Status) .Include(s => s.NextStatus) .AsNoTracking() .Where(es => es.StatusId == Draft && es.Status != null) .GroupBy(s => s.StatusId) .Select(g => new { Status = g.Select(s => s.Status).FirstOrDefault(), NextStatus = g.Select(s => s.NextStatus).ToList() }) .FirstOrDefaultAsync(); }); // Await all prerequisite checks at once. await Task.WhenAll( hasUploadPermissionTask, hasProjectPermissionTask, projectTask, expenseTypeTask, paymentModeTask, statusMappingTask, paidByTask ); // 2. Aggregate and Check Results if (!await hasUploadPermissionTask || !await hasProjectPermissionTask) { _logger.LogWarning("Access DENIED for employee {EmployeeId} on project {ProjectId}.", loggedInEmployee.Id, dto.ProjectId); return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to upload expenses for this project.", 403); } var validationErrors = new List(); var project = await projectTask; var expenseType = await expenseTypeTask; var paymentMode = await paymentModeTask; var statusMapping = await statusMappingTask; var paidBy = await paidByTask; if (project == null) validationErrors.Add("Project not found."); if (paidBy == null) validationErrors.Add("Paid by employee not found"); if (expenseType == null) validationErrors.Add("Expense Type not found."); if (paymentMode == null) validationErrors.Add("Payment Mode not found."); if (statusMapping == null) validationErrors.Add("Default status 'Draft' not found."); if (validationErrors.Any()) { await transaction.RollbackAsync(); var errorMessage = string.Join(" ", validationErrors); _logger.LogWarning("Expense creation failed due to validation errors: {ValidationErrors}", errorMessage); return ApiResponse.ErrorResponse("Invalid input data.", errorMessage, 400); } // 3. Entity Creation var expense = _mapper.Map(dto); expense.CreatedById = loggedInEmployee.Id; expense.CreatedAt = DateTime.UtcNow; expense.TenantId = tenantId; expense.IsActive = true; expense.StatusId = Draft; _context.Expenses.Add(expense); // 4. Process Attachments if (dto.BillAttachments?.Any() ?? false) { await ProcessAndUploadAttachmentsAsync(dto.BillAttachments, expense, loggedInEmployee.Id, tenantId); } // 5. Database Commit await _context.SaveChangesAsync(); // 6. Transaction Commit await transaction.CommitAsync(); var response = _mapper.Map(expense); response.PaidBy = _mapper.Map(paidBy); response.Project = _mapper.Map(project); response.Status = _mapper.Map(statusMapping!.Status); response.NextStatus = _mapper.Map>(statusMapping.NextStatus); response.PaymentMode = _mapper.Map(paymentMode); response.ExpensesType = _mapper.Map(expenseType); _logger.LogInfo("Successfully created Expense {ExpenseId} for Project {ProjectId}.", expense.Id, expense.ProjectId); return ApiResponse.SuccessResponse(response, "Expense created successfully.", 201); } catch (ArgumentException ex) // Catches bad Base64 from attachment pre-validation { await transaction.RollbackAsync(); _logger.LogError(ex, "Invalid argument during expense creation for project {ProjectId}.", dto.ProjectId); return ApiResponse.ErrorResponse("Invalid Request Data.", 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); } catch (Exception ex) // General-purpose catch for unexpected errors (e.g., S3 or DB connection failure) { await transaction.RollbackAsync(); _logger.LogError(ex, "An unhandled exception occurred while creating an expense for project {ProjectId}.", dto.ProjectId); return ApiResponse.ErrorResponse("An internal server error occurred.", new { Message = ex.Message, StackTrace = ex.StackTrace, Source = ex.Source, innerexcption = new { Message = ex.InnerException?.Message, StackTrace = ex.InnerException?.StackTrace, Source = ex.InnerException?.Source, } }, 500); } } public async Task> ChangeStatus(ExpenseRecordDto model, Employee loggedInEmployee, Guid tenantId) { var existingExpense = await _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) .FirstOrDefaultAsync(e => e.Id == model.ExpenseId && e.StatusId != model.StatusId && e.TenantId == tenantId); if (existingExpense == null) { return ApiResponse.ErrorResponse("Expense not found", "Expense not found", 404); } var statusMappingTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesStatusMapping .Include(s => s.NextStatus) .FirstOrDefaultAsync(s => s.StatusId == existingExpense.StatusId && s.NextStatus != null && s.NextStatusId == model.StatusId && s.TenantId == tenantId); }); var statusPermissionMappingTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.StatusPermissionMapping.Where(sp => sp.StatusId == model.StatusId).ToListAsync(); }); // Await all prerequisite checks at once. await Task.WhenAll(statusMappingTask, statusPermissionMappingTask); var statusMapping = await statusMappingTask; var statusPermissions = await statusPermissionMappingTask; if (statusMapping == null) { return ApiResponse.ErrorResponse("There is no follow-up status for currect status"); } if (statusPermissions.Any()) { foreach (var sp in statusPermissions) { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); if (!await permissionService.HasPermission(sp.PermissionId, loggedInEmployee.Id)) { _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to change status of expense from status {StatusId} to {NextStatusId}", loggedInEmployee.Id, statusMapping.StatusId, statusMapping.NextStatusId); return ApiResponse.ErrorResponse("You do not have permission", "Access Denied", 403); } } } else if (existingExpense.CreatedById != loggedInEmployee.Id) { _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to change status of expense from status {StatusId} to {NextStatusId}", loggedInEmployee.Id, statusMapping.StatusId, statusMapping.NextStatusId); return ApiResponse.ErrorResponse("You do not have permission", "Access Denied", 403); } var existingEntity = _updateLogHelper.EntityToBsonDocument(existingExpense); existingExpense.StatusId = statusMapping.NextStatusId; existingExpense.Status = statusMapping.NextStatus; _context.ExpenseLogs.Add(new ExpenseLog { ExpenseId = existingExpense.Id, Action = statusMapping.NextStatus!.Name, UpdatedById = loggedInEmployee.Id, Comment = model.Comment }); try { await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException dbEx) { _logger.LogError(dbEx, "Error occured while update status of expanse."); return ApiResponse.ErrorResponse("Error occured while update status of expanse.", new { Message = dbEx.Message, StackTrace = dbEx.StackTrace, Source = dbEx.Source, innerexcption = new { Message = dbEx.InnerException?.Message, StackTrace = dbEx.InnerException?.StackTrace, Source = dbEx.InnerException?.Source, } }, 500); } try { var updateLog = new UpdateLogsObject { EntityId = existingExpense.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = existingEntity, UpdatedAt = DateTime.UtcNow }; var mongoDBTask = Task.Run(async () => { await _updateLogHelper.PushToUpdateLogsAsync(updateLog, Collection); }); var getNextStatusTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesStatusMapping .Include(s => s.NextStatus) .FirstOrDefaultAsync(s => s.StatusId == existingExpense.StatusId && s.NextStatus != null && s.TenantId == tenantId); }); await Task.WhenAll(mongoDBTask, getNextStatusTask); var getNextStatus = await getNextStatusTask; var response = _mapper.Map(existingExpense); if (getNextStatus != null) { response.NextStatus = _mapper.Map>(getNextStatus.NextStatus); } return ApiResponse.SuccessResponse(response); } catch (Exception ex) { _logger.LogError(ex, "Error occured while Saving old entity in mongoDb"); return ApiResponse.ErrorResponse("Error occured while update status of expanse.", new { Message = ex.Message, StackTrace = ex.StackTrace, Source = ex.Source, innerexcption = new { Message = ex.InnerException?.Message, StackTrace = ex.InnerException?.StackTrace, Source = ex.InnerException?.Source, } }, 500); } } /// /// Changes the status of an expense record, performing validation, permission checks, and logging. /// /// The DTO containing the expense ID and the target status ID. /// The employee performing the action. /// The ID of the tenant owning the expense. /// An ApiResponse containing the updated expense details or an error. public async Task> ChangeStatusAsync(ExpenseRecordDto model, Employee loggedInEmployee, Guid tenantId) { // --- 1. Fetch Existing Expense --- // We include all related entities needed for the final response mapping to avoid multiple database trips. // The query also ensures we don't process a request if the status is already the one requested. var existingExpense = await _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) .FirstOrDefaultAsync(e => e.Id == model.ExpenseId && e.StatusId != model.StatusId && e.TenantId == tenantId); if (existingExpense == null) { // Use structured logging for better searchability. _logger.LogWarning("Attempted to change status for a non-existent or already-updated expense. ExpenseId: {ExpenseId}, TenantId: {TenantId}", model.ExpenseId, tenantId); return ApiResponse.ErrorResponse("Expense not found or status is already set.", "Expense not found", 404); } _logger.LogInfo("Initiating status change for ExpenseId: {ExpenseId} from StatusId: {OldStatusId} to {NewStatusId}", existingExpense.Id, existingExpense.StatusId, model.StatusId); // --- 2. Concurrently Check Prerequisites --- // We run status validation and permission fetching in parallel for efficiency. // Using Task.Run with an async lambda is the standard way to start a concurrent, // CPU- or I/O-bound operation on a background thread. // Task to validate if the requested status change is a valid transition. var statusMappingTask = Task.Run(async () => { // 'await using' ensures the DbContext created by the factory is properly disposed // within the scope of this background task. await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesStatusMapping .Include(s => s.NextStatus) .FirstOrDefaultAsync(s => s.StatusId == existingExpense.StatusId && s.NextStatusId == model.StatusId && s.TenantId == tenantId); }); // Task to fetch all permissions required for the *target* status. var statusPermissionMappingTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.StatusPermissionMapping .Where(sp => sp.StatusId == model.StatusId && sp.TenantId == tenantId) .ToListAsync(); }); // Await both tasks to complete concurrently. await Task.WhenAll(statusMappingTask, statusPermissionMappingTask); // Now you can safely get the results. var statusMapping = await statusMappingTask; var statusPermissions = await statusPermissionMappingTask; // --- 3. Validate Status Transition and Permissions --- if (statusMapping == null) { _logger.LogWarning("Invalid status transition attempted for ExpenseId: {ExpenseId}. From StatusId: {FromStatusId} to {ToStatusId}", existingExpense.Id, existingExpense.StatusId, model.StatusId); return ApiResponse.ErrorResponse("This status change is not allowed.", "Invalid Transition", 400); } // Check permissions. The logic is: // 1. If the target status has specific permissions defined, the user must have at least one of them. // 2. If no permissions are defined for the target status, only the original creator of the expense can change it. bool hasPermission = false; if (statusPermissions.Any()) { // Using a scope to resolve scoped services like PermissionServices. using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); foreach (var sp in statusPermissions) { if (await permissionService.HasPermission(sp.PermissionId, loggedInEmployee.Id)) { hasPermission = true; break; // User has one of the required permissions, no need to check further. } } } else if (existingExpense.CreatedById == loggedInEmployee.Id) { // Fallback: If no permissions are required for the status, allow the creator to make the change. hasPermission = true; } if (!hasPermission) { _logger.LogWarning("Access DENIED for EmployeeId: {EmployeeId} attempting to change status of ExpenseId: {ExpenseId} to StatusId: {NewStatusId}", loggedInEmployee.Id, existingExpense.Id, model.StatusId); return ApiResponse.ErrorResponse("You do not have the required permissions to perform this action.", "Access Denied", 403); } // --- 4. Update Expense and Add Log (in a transaction) --- var existingEntityBson = _updateLogHelper.EntityToBsonDocument(existingExpense); // Capture state for audit log BEFORE changes. existingExpense.StatusId = statusMapping.NextStatusId; existingExpense.Status = statusMapping.NextStatus; // Assigning the included entity for the response mapping. _context.ExpenseLogs.Add(new ExpenseLog { ExpenseId = existingExpense.Id, Action = $"Status changed to '{statusMapping.NextStatus!.Name}'", UpdatedById = loggedInEmployee.Id, Comment = model.Comment, TenantId = tenantId }); try { await _context.SaveChangesAsync(); _logger.LogInfo("Successfully updated status for ExpenseId: {ExpenseId} to StatusId: {NewStatusId}", existingExpense.Id, existingExpense.StatusId); } catch (DbUpdateConcurrencyException dbEx) { // This error occurs if the record was modified by someone else after we fetched it. _logger.LogError(dbEx, "Concurrency conflict while updating status for ExpenseId: {ExpenseId}. The record may have been modified by another user.", existingExpense.Id); return ApiResponse.ErrorResponse("The expense was modified by another user. Please refresh and try again.", "Concurrency Error", 409); // 409 Conflict is appropriate } // --- 5. Perform Post-Save Actions (Audit Log and Fetching Next State for UI) --- try { // Task to save the detailed audit log to a separate system (e.g., MongoDB). var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = existingExpense.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = existingEntityBson, UpdatedAt = DateTime.UtcNow }, Collection); // Task to get all possible next statuses from the *new* current state to help the UI. // NOTE: This now fetches a list of all possible next states, which is more useful for a UI. var getNextStatusesTask = _dbContextFactory.CreateDbContextAsync().ContinueWith(t => { var dbContext = t.Result; return dbContext.ExpensesStatusMapping .Include(s => s.NextStatus) .Where(s => s.StatusId == existingExpense.StatusId && s.NextStatus != null && s.TenantId == tenantId) .Select(s => s.NextStatus) // Select only the status object .ToListAsync() .ContinueWith(res => { dbContext.Dispose(); // Ensure the context is disposed return res.Result; }); }).Unwrap(); await Task.WhenAll(mongoDBTask, getNextStatusesTask); var nextPossibleStatuses = await getNextStatusesTask; var response = _mapper.Map(existingExpense); if (nextPossibleStatuses != null) { // The response DTO should have a property like: public List NextAvailableStatuses { get; set; } response.NextStatus = _mapper.Map>(nextPossibleStatuses); } return ApiResponse.SuccessResponse(response); } catch (Exception ex) { // This catch block handles errors from post-save operations like MongoDB logging. // The primary update was successful, but we must log this failure. _logger.LogError(ex, "Error occurred during post-save operations for ExpenseId: {ExpenseId} (e.g., audit logging). The primary status change was successful.", existingExpense.Id); // We can still return a success response because the main operation succeeded, // but we should not block the user for a failed audit log. // Alternatively, if audit logging is critical, you could return an error. // Here, we choose to return success but log the ancillary failure. var response = _mapper.Map(existingExpense); return ApiResponse.SuccessResponse(response, "Status updated, but a post-processing error occurred."); } } public async Task> UpdateExpanseAsync(Guid id, UpdateExpensesDto model, Employee loggedInEmployee, Guid tenantId) { var exsitingExpense = await _context.Expenses.FirstOrDefaultAsync(e => e.Id == model.Id && e.TenantId == tenantId); if (exsitingExpense == null) { return ApiResponse.ErrorResponse("Expense not found", "Expense not found", 404); } _mapper.Map(model, exsitingExpense); _context.Entry(exsitingExpense).State = EntityState.Modified; try { await _context.SaveChangesAsync(); _logger.LogInfo("Successfully updated project {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); } catch (DbUpdateConcurrencyException ex) { // --- Step 3: Handle Concurrency Conflicts --- // This happens if another user modified the project after we fetched it. _logger.LogError(ex, "Concurrency conflict while updating project {ProjectId} ", id); return ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409); } var response = _mapper.Map(exsitingExpense); return ApiResponse.SuccessResponse(response); } public void Delete(int id) { } #region =================================================================== Helper Functions =================================================================== /// /// 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; } /// /// Processes and uploads attachments concurrently, then adds the resulting entities to the main DbContext. /// private async Task ProcessAndUploadAttachmentsAsync(IEnumerable attachments, Expenses expense, Guid employeeId, Guid tenantId) { // Pre-validate all attachments to fail fast before any uploads. foreach (var attachment in attachments) { if (string.IsNullOrWhiteSpace(attachment.Base64Data) || !_s3Service.IsBase64String(attachment.Base64Data)) { throw new ArgumentException($"Invalid or missing Base64 data for attachment: {attachment.FileName ?? "N/A"}"); } } var batchId = Guid.NewGuid(); // Create a list of tasks to be executed concurrently. var processingTasks = attachments.Select(attachment => ProcessSingleAttachmentAsync(attachment, expense, employeeId, tenantId, batchId) ).ToList(); var results = await Task.WhenAll(processingTasks); // This part is thread-safe as it runs after all concurrent tasks are complete. foreach (var (document, billAttachment) in results) { _context.Documents.Add(document); _context.BillAttachments.Add(billAttachment); } _logger.LogInfo("{AttachmentCount} attachments processed and staged for saving.", results.Length); } /// /// Handles the logic for a single attachment: upload to S3 and create corresponding entities. /// private async Task<(Document document, BillAttachments billAttachment)> ProcessSingleAttachmentAsync( FileUploadModel attachment, Expenses expense, Guid employeeId, Guid tenantId, Guid batchId) { var base64Data = attachment.Base64Data!.Contains(',') ? attachment.Base64Data[(attachment.Base64Data.IndexOf(",") + 1)..] : attachment.Base64Data; var fileType = _s3Service.GetContentTypeFromBase64(base64Data); var fileName = _s3Service.GenerateFileName(fileType, expense.Id, "Expense"); var objectKey = $"tenant-{tenantId}/project-{expense.ProjectId}/Expenses/{fileName}"; // Await the I/O-bound upload operation directly. await _s3Service.UploadFileAsync(base64Data, fileType, objectKey); _logger.LogInfo("Uploaded file to S3 with key: {ObjectKey}", objectKey); return CreateAttachmentEntities(batchId, expense.Id, employeeId, tenantId, objectKey, attachment); } /// /// A private static helper method to create Document and BillAttachment entities. /// This remains unchanged as it's a pure data-shaping method. /// private static (Document document, BillAttachments billAttachment) CreateAttachmentEntities( Guid batchId, Guid expenseId, Guid uploadedById, Guid tenantId, string s3Key, FileUploadModel attachmentDto) { var document = new Document { BatchId = batchId, UploadedById = uploadedById, FileName = attachmentDto.FileName ?? "", ContentType = attachmentDto.ContentType ?? "", S3Key = s3Key, FileSize = attachmentDto.FileSize, UploadedAt = DateTime.UtcNow, TenantId = tenantId }; var billAttachment = new BillAttachments { Document = document, ExpensesId = expenseId, TenantId = tenantId }; return (document, billAttachment); } #endregion } }