From d27cdee72d5b8d73f36a51bc239056cef3b6ae47 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Sat, 12 Jul 2025 13:13:29 +0530 Subject: [PATCH 01/50] Refactor project report APIs to improve performance and readability --- Marco.Pms.CacheHelper/ReportCache.cs | 45 ++ .../ProjectReportEmailMongoDB.cs | 16 + .../Controllers/ReportController.cs | 530 +++++++++++++++--- Marco.Pms.Services/Service/EmailSender.cs | 26 +- 4 files changed, 523 insertions(+), 94 deletions(-) create mode 100644 Marco.Pms.CacheHelper/ReportCache.cs create mode 100644 Marco.Pms.Model/MongoDBModels/ProjectReportEmailMongoDB.cs diff --git a/Marco.Pms.CacheHelper/ReportCache.cs b/Marco.Pms.CacheHelper/ReportCache.cs new file mode 100644 index 0000000..76009a4 --- /dev/null +++ b/Marco.Pms.CacheHelper/ReportCache.cs @@ -0,0 +1,45 @@ +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.MongoDBModels; +using Microsoft.Extensions.Configuration; +using MongoDB.Driver; + +namespace Marco.Pms.CacheHelper +{ + public class ReportCache + { + private readonly ApplicationDbContext _context; + private readonly IMongoCollection _projectReportCollection; + public ReportCache(ApplicationDbContext context, IConfiguration configuration) + { + var connectionString = configuration["MongoDB:ConnectionString"]; + _context = context; + var mongoUrl = new MongoUrl(connectionString); + var client = new MongoClient(mongoUrl); // Your MongoDB connection string + var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name + _projectReportCollection = mongoDB.GetCollection("ProjectReportMail"); + } + + /// + /// Retrieves project report emails from the cache based on their sent status. + /// + /// True to get sent reports, false to get unsent reports. + /// A list of ProjectReportEmailMongoDB objects. + public async Task> GetProjectReportMailFromCache(bool isSent) + { + var filter = Builders.Filter.Eq(p => p.IsSent, isSent); + var reports = await _projectReportCollection.Find(filter).ToListAsync(); + return reports; + } + + /// + /// Adds a project report email to the cache. + /// + /// The ProjectReportEmailMongoDB object to add. + /// A Task representing the asynchronous operation. + public async Task AddProjectReportMailToCache(ProjectReportEmailMongoDB report) + { + // Consider adding validation or logging here. + await _projectReportCollection.InsertOneAsync(report); + } + } +} diff --git a/Marco.Pms.Model/MongoDBModels/ProjectReportEmailMongoDB.cs b/Marco.Pms.Model/MongoDBModels/ProjectReportEmailMongoDB.cs new file mode 100644 index 0000000..519ea4f --- /dev/null +++ b/Marco.Pms.Model/MongoDBModels/ProjectReportEmailMongoDB.cs @@ -0,0 +1,16 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Marco.Pms.Model.MongoDBModels +{ + public class ProjectReportEmailMongoDB + { + [BsonId] // Tells MongoDB this is the primary key (_id) + [BsonRepresentation(BsonType.ObjectId)] // Optional: if your _id is ObjectId + public string Id { get; set; } = string.Empty; + public string? Body { get; set; } + public string? Subject { get; set; } + public List? Receivers { get; set; } + public bool IsSent { get; set; } = false; + } +} diff --git a/Marco.Pms.Services/Controllers/ReportController.cs b/Marco.Pms.Services/Controllers/ReportController.cs index 11dec58..717a273 100644 --- a/Marco.Pms.Services/Controllers/ReportController.cs +++ b/Marco.Pms.Services/Controllers/ReportController.cs @@ -1,16 +1,19 @@ -using System.Data; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Mail; -using Marco.Pms.Model.Employees; using Marco.Pms.Model.Mail; +using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Utilities; using Marco.Pms.Services.Helpers; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; using MongoDB.Driver; +using System.Data; +using System.Globalization; +using System.Net.Mail; namespace Marco.Pms.Services.Controllers { @@ -25,7 +28,11 @@ namespace Marco.Pms.Services.Controllers private readonly UserHelper _userHelper; private readonly IWebHostEnvironment _env; private readonly ReportHelper _reportHelper; - public ReportController(ApplicationDbContext context, IEmailSender emailSender, ILoggingService logger, UserHelper userHelper, IWebHostEnvironment env, ReportHelper reportHelper) + private readonly IConfiguration _configuration; + private readonly CacheUpdateHelper _cache; + private readonly IServiceScopeFactory _serviceScopeFactory; + public ReportController(ApplicationDbContext context, IEmailSender emailSender, ILoggingService logger, UserHelper userHelper, + IWebHostEnvironment env, ReportHelper reportHelper, IConfiguration configuration, CacheUpdateHelper cache, IServiceScopeFactory serviceScopeFactory) { _context = context; _emailSender = emailSender; @@ -33,27 +40,122 @@ namespace Marco.Pms.Services.Controllers _userHelper = userHelper; _env = env; _reportHelper = reportHelper; + _configuration = configuration; + _cache = cache; + _serviceScopeFactory = serviceScopeFactory; } - [HttpPost("set-mail")] + /// + /// Adds new mail details for a project report. + /// + /// The mail details data. + /// An API response indicating success or failure. + [HttpPost("mail-details")] // More specific route for adding mail details public async Task AddMailDetails([FromBody] MailDetailsDto mailDetailsDto) { + // 1. Get Tenant ID and Basic Authorization Check Guid tenantId = _userHelper.GetTenantId(); - MailDetails mailDetails = new MailDetails + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Authorization Error: Attempt to add mail details with an empty or invalid tenant ID."); + return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "Tenant ID not found or invalid.", 401)); + } + + // 2. Input Validation (Leverage Model Validation attributes on DTO) + if (mailDetailsDto == null) + { + _logger.LogWarning("Validation Error: MailDetails DTO is null. TenantId: {TenantId}", tenantId); + return BadRequest(ApiResponse.ErrorResponse("Invalid Data", "Request body is empty.", 400)); + } + + // Ensure ProjectId and Recipient are not empty + if (mailDetailsDto.ProjectId == Guid.Empty) + { + _logger.LogWarning("Validation Error: Project ID is empty. TenantId: {TenantId}", tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Project ID cannot be empty.", 400)); + } + + if (string.IsNullOrWhiteSpace(mailDetailsDto.Recipient)) + { + _logger.LogWarning("Validation Error: Recipient email is empty. ProjectId: {ProjectId}, TenantId: {TenantId}", mailDetailsDto.ProjectId, tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Recipient email cannot be empty.", 400)); + } + + // Optional: Validate email format using regex or System.Net.Mail.MailAddress + try + { + var mailAddress = new MailAddress(mailDetailsDto.Recipient); + _logger.LogInfo("nothing"); + } + catch (FormatException) + { + _logger.LogWarning("Validation Error: Invalid recipient email format '{Recipient}'. ProjectId: {ProjectId}, TenantId: {TenantId}", mailDetailsDto.Recipient, mailDetailsDto.ProjectId, tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Invalid recipient email format.", 400)); + } + + // 3. Validate MailListId (Foreign Key Check) + // Ensure the MailListId refers to an existing MailBody (template) within the same tenant. + if (mailDetailsDto.MailListId != Guid.Empty) // Only validate if a MailListId is provided + { + bool mailTemplateExists; + try + { + mailTemplateExists = await _context.MailingList + .AsNoTracking() + .AnyAsync(m => m.Id == mailDetailsDto.MailListId && m.TenantId == tenantId); + } + catch (Exception ex) + { + _logger.LogError("Database Error: Failed to check existence of MailListId '{MailListId}' for TenantId: {TenantId}. : {Error}", mailDetailsDto.MailListId, tenantId, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while validating mail template.", 500)); + } + + if (!mailTemplateExists) + { + _logger.LogWarning("Validation Error: Provided MailListId '{MailListId}' does not exist or does not belong to TenantId: {TenantId}.", mailDetailsDto.MailListId, tenantId); + return NotFound(ApiResponse.ErrorResponse("Invalid Mail Template", "The specified mail template (MailListId) was not found or accessible.", 404)); + } + } + // If MailListId can be null/empty and implies no specific template, adjust logic accordingly. + // Currently assumes it must exist if provided. + + // 4. Create and Add New Mail Details + var newMailDetails = new MailDetails { ProjectId = mailDetailsDto.ProjectId, Recipient = mailDetailsDto.Recipient, Schedule = mailDetailsDto.Schedule, MailListId = mailDetailsDto.MailListId, - TenantId = tenantId + TenantId = tenantId, }; - _context.MailDetails.Add(mailDetails); - await _context.SaveChangesAsync(); - return Ok("Success"); + + try + { + _context.MailDetails.Add(newMailDetails); + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully added new mail details with ID {MailDetailsId} for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}.", newMailDetails.Id, newMailDetails.ProjectId, newMailDetails.Recipient, tenantId); + + // 5. Return Success Response (201 Created is ideal for resource creation) + return StatusCode(201, ApiResponse.SuccessResponse( + newMailDetails, // Return the newly created object (or a DTO of it) + "Mail details added successfully.", + 201)); + } + catch (DbUpdateException dbEx) + { + _logger.LogError("Database Error: Failed to save new mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}. : {Error}", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId, dbEx.Message); + // Check for specific constraint violations if applicable (e.g., duplicate recipient for a project) + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while saving the mail details.", 500)); + } + catch (Exception ex) + { + _logger.LogError("Unexpected Error: An unhandled exception occurred while adding mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}. : {Error}", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500)); + } } - [HttpPost("mail-template")] - public async Task AddMailTemplate([FromBody] MailTemeplateDto mailTemeplateDto) + [HttpPost("mail-template1")] + public async Task AddMailTemplate1([FromBody] MailTemeplateDto mailTemeplateDto) { Guid tenantId = _userHelper.GetTenantId(); if (string.IsNullOrWhiteSpace(mailTemeplateDto.Body) && string.IsNullOrWhiteSpace(mailTemeplateDto.Title)) @@ -80,116 +182,376 @@ namespace Marco.Pms.Services.Controllers return Ok("Success"); } + /// + /// Adds a new mail template. + /// + /// The mail template data. + /// An API response indicating success or failure. + [HttpPost("mail-template")] // More specific route for adding a template + public async Task AddMailTemplate([FromBody] MailTemeplateDto mailTemplateDto) // Renamed parameter for consistency + { + // 1. Get Tenant ID and Basic Authorization Check + Guid tenantId = _userHelper.GetTenantId(); + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Authorization Error: Attempt to add mail template with an empty or invalid tenant ID."); + return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "Tenant ID not found or invalid.", 401)); + } + + // 2. Input Validation (Moved to model validation if possible, or keep explicit) + // Use proper model validation attributes ([Required], [StringLength]) on MailTemeplateDto + // and rely on ASP.NET Core's automatic model validation if possible. + // If not, these checks are good. + if (mailTemplateDto == null) + { + _logger.LogWarning("Validation Error: Mail template DTO is null."); + return BadRequest(ApiResponse.ErrorResponse("Invalid Data", "Request body is empty.", 400)); + } + + if (string.IsNullOrWhiteSpace(mailTemplateDto.Title)) + { + _logger.LogWarning("Validation Error: Mail template title is empty or whitespace. TenantId: {TenantId}", tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Mail template title cannot be empty.", 400)); + } + + // The original logic checked both body and title, but often a template needs at least a title. + // Re-evalute if body can be empty. If so, remove the body check. Assuming title is always mandatory. + // If both body and title are empty, it's definitely invalid. + if (string.IsNullOrWhiteSpace(mailTemplateDto.Body) && string.IsNullOrWhiteSpace(mailTemplateDto.Subject)) + { + _logger.LogWarning("Validation Error: Mail template body and subject are both empty or whitespace for title '{Title}'. TenantId: {TenantId}", mailTemplateDto.Title, tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Mail template body or subject must be provided.", 400)); + } + + // 3. Check for Existing Template Title (Case-Insensitive) + // Use AsNoTracking() for read-only query + MailingList? existingTemplate; + try + { + existingTemplate = await _context.MailingList + .AsNoTracking() // Important for read-only checks + .FirstOrDefaultAsync(t => t.Title.ToLower() == mailTemplateDto.Title.ToLower() && t.TenantId == tenantId); // IMPORTANT: Filter by TenantId! + } + catch (Exception ex) + { + _logger.LogError("Database Error: Failed to check for existing mail template with title '{Title}' for TenantId: {TenantId}.: {Error}", mailTemplateDto.Title, tenantId, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while checking for existing templates.", 500)); + } + + + if (existingTemplate != null) + { + _logger.LogWarning("Conflict Error: User tries to add email template with title '{Title}' which already exists for TenantId: {TenantId}.", mailTemplateDto.Title, tenantId); + return Conflict(ApiResponse.ErrorResponse("Conflict", $"Email template with title '{mailTemplateDto.Title}' already exists.", 409)); + } + + // 4. Create and Add New Template + var newMailingList = new MailingList + { + Title = mailTemplateDto.Title, + Body = mailTemplateDto.Body, + Subject = mailTemplateDto.Subject, + Keywords = mailTemplateDto.Keywords, + TenantId = tenantId, + }; + + try + { + _context.MailingList.Add(newMailingList); + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully added new mail template with ID {TemplateId} and title '{Title}' for TenantId: {TenantId}.", newMailingList.Id, newMailingList.Title, tenantId); + + // 5. Return Success Response (201 Created is ideal for resource creation) + // It's good practice to return the created resource or its ID. + return StatusCode(201, ApiResponse.SuccessResponse( + newMailingList, // Return the newly created object (or a DTO of it) + "Mail template added successfully.", + 201)); + } + catch (DbUpdateException dbEx) + { + _logger.LogError("Database Error: Failed to save new mail template '{Title}' for TenantId: {TenantId}. : {Error}", mailTemplateDto.Title, tenantId, dbEx.Message); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while saving the mail template.", 500)); + } + catch (Exception ex) + { + _logger.LogError("Unexpected Error: An unhandled exception occurred while adding mail template '{Title}' for TenantId: {TenantId}. : {Error}", mailTemplateDto.Title, tenantId, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500)); + } + } + [HttpGet("project-statistics")] public async Task SendProjectReport() { Guid tenantId = _userHelper.GetTenantId(); - // Use AsNoTracking() for read-only queries to improve performance - List mailDetails = await _context.MailDetails + // 1. OPTIMIZATION: Perform grouping and projection on the database server. + // This is far more efficient than loading all entities into memory. + var projectMailGroups = await _context.MailDetails .AsNoTracking() - .Include(m => m.MailBody) .Where(m => m.TenantId == tenantId) - .ToListAsync(); - - int successCount = 0; - int notFoundCount = 0; - int invalidIdCount = 0; - - var groupedMails = mailDetails .GroupBy(m => new { m.ProjectId, m.MailListId }) .Select(g => new { ProjectId = g.Key.ProjectId, - MailListId = g.Key.MailListId, Recipients = g.Select(m => m.Recipient).Distinct().ToList(), - MailBody = g.FirstOrDefault()?.MailBody?.Body ?? "", - Subject = g.FirstOrDefault()?.MailBody?.Subject ?? string.Empty, + // Project the mail body and subject from the first record in the group + MailInfo = g.Select(m => new { Body = m.MailBody != null ? m.MailBody.Body : "", Subject = m.MailBody != null ? m.MailBody.Subject : "" }).FirstOrDefault() }) - .ToList(); + .ToListAsync(); - var semaphore = new SemaphoreSlim(1); - - // Using Task.WhenAll to send reports concurrently for better performance - var sendTasks = groupedMails.Select(async mailDetail => + if (!projectMailGroups.Any()) { - await semaphore.WaitAsync(); - try + return Ok(ApiResponse.SuccessResponse(new { }, "No projects found to send reports for.", 200)); + } + + int successCount = 0; + int notFoundCount = 0; + int invalidIdCount = 0; + int failureCount = 0; + + // 2. OPTIMIZATION: Use true concurrency by removing SemaphoreSlim(1) + // and giving each task its own isolated set of services (including DbContext). + var sendTasks = projectMailGroups.Select(async mailGroup => + { + // SOLUTION: Create a new Dependency Injection scope for each parallel task. + using (var scope = _serviceScopeFactory.CreateScope()) { - var response = await GetProjectStatistics(mailDetail.ProjectId, mailDetail.Recipients, mailDetail.MailBody, mailDetail.Subject, tenantId); - if (response.StatusCode == 200) - Interlocked.Increment(ref successCount); - else if (response.StatusCode == 404) - Interlocked.Increment(ref notFoundCount); - else if (response.StatusCode == 400) - Interlocked.Increment(ref invalidIdCount); - } - finally - { - semaphore.Release(); + // Resolve a new instance of the helper from this isolated scope. + // This ensures each task gets its own thread-safe DbContext. + var reportHelper = scope.ServiceProvider.GetRequiredService(); + + try + { + // Ensure MailInfo and ProjectId are valid before proceeding + if (mailGroup.MailInfo == null || mailGroup.ProjectId == Guid.Empty) + { + Interlocked.Increment(ref invalidIdCount); + return; + } + + var response = await reportHelper.GetProjectStatistics( + mailGroup.ProjectId, + mailGroup.Recipients, + mailGroup.MailInfo.Body, + mailGroup.MailInfo.Subject, + tenantId); + + // Use a switch expression for cleaner counting + switch (response.StatusCode) + { + case 200: Interlocked.Increment(ref successCount); break; + case 404: Interlocked.Increment(ref notFoundCount); break; + case 400: Interlocked.Increment(ref invalidIdCount); break; + default: Interlocked.Increment(ref failureCount); break; + } + } + catch (Exception ex) + { + // 3. OPTIMIZATION: Make the process resilient. + // If one task fails unexpectedly, log it and continue with others. + _logger.LogError("Failed to send report for project {ProjectId} : {Error}", mailGroup.ProjectId, ex.Message); + Interlocked.Increment(ref failureCount); + } } }).ToList(); await Task.WhenAll(sendTasks); - //var response = await GetProjectStatistics(Guid.Parse("2618eb89-2823-11f0-9d9e-bc241163f504"), "ashutosh.nehete@marcoaiot.com", tenantId); + var summaryMessage = $"Processing complete. Success: {successCount}, Not Found: {notFoundCount}, Invalid ID: {invalidIdCount}, Failures: {failureCount}."; _logger.LogInfo( - "Emails of project reports sent for tenant {TenantId}. Successfully sent: {SuccessCount}, Projects not found: {NotFoundCount}, Invalid IDs: {InvalidIdsCount}", - tenantId, successCount, notFoundCount, invalidIdCount); + "Project report sending complete for tenant {TenantId}. Success: {SuccessCount}, Not Found: {NotFoundCount}, Invalid ID: {InvalidIdCount}, Failures: {FailureCount}", + tenantId, successCount, notFoundCount, invalidIdCount, failureCount); return Ok(ApiResponse.SuccessResponse( - new { }, - $"Reports sent successfully: {successCount}. Projects not found: {notFoundCount}. Invalid IDs: {invalidIdCount}.", + new { successCount, notFoundCount, invalidIdCount, failureCount }, + summaryMessage, 200)); } - /// - /// Retrieves project statistics for a given project ID and sends an email report. - /// - /// The ID of the project. - /// The email address of the recipient. - /// An ApiResponse indicating the success or failure of retrieving statistics and sending the email. - private async Task> GetProjectStatistics(Guid projectId, List recipientEmails, string body, string subject, Guid tenantId) + + //[HttpPost("add-report-mail1")] + //public async Task StoreProjectStatistics1() + //{ + + // Guid tenantId = _userHelper.GetTenantId(); + + // // Use AsNoTracking() for read-only queries to improve performance + // List mailDetails = await _context.MailDetails + // .AsNoTracking() + // .Include(m => m.MailBody) + // .Where(m => m.TenantId == tenantId) + // .ToListAsync(); + + // var groupedMails = mailDetails + // .GroupBy(m => new { m.ProjectId, m.MailListId }) + // .Select(g => new + // { + // ProjectId = g.Key.ProjectId, + // MailListId = g.Key.MailListId, + // Recipients = g.Select(m => m.Recipient).Distinct().ToList(), + // MailBody = g.FirstOrDefault()?.MailBody?.Body ?? "", + // Subject = g.FirstOrDefault()?.MailBody?.Subject ?? string.Empty, + // }) + // .ToList(); + // foreach (var groupMail in groupedMails) + // { + // var projectId = groupMail.ProjectId; + // var body = groupMail.MailBody; + // var subject = groupMail.Subject; + // var receivers = groupMail.Recipients; + // if (projectId == Guid.Empty) + // { + // _logger.LogError("Provided empty project ID while fetching project report."); + // return NotFound(ApiResponse.ErrorResponse("Provided empty Project ID.", "Provided empty Project ID.", 400)); + // } + + + // var statisticReport = await _reportHelper.GetDailyProjectReport(projectId, tenantId); + + // if (statisticReport == null) + // { + // _logger.LogWarning("User attempted to fetch project progress for project ID {ProjectId} but not found.", projectId); + // return NotFound(ApiResponse.ErrorResponse("Project not found.", "Project not found.", 404)); + // } + // var date = statisticReport.Date.ToString("dd-MMM-yyyy", CultureInfo.InvariantCulture); + + // // Send Email + // var emailBody = await _emailSender.SendProjectStatisticsEmail(new List(), body, subject, statisticReport); + // var subjectReplacements = new Dictionary + // { + // {"DATE", date }, + // {"PROJECT_NAME", statisticReport.ProjectName} + // }; + // foreach (var item in subjectReplacements) + // { + // subject = subject.Replace($"{{{{{item.Key}}}}}", item.Value); + // } + // string env = _configuration["environment:Title"] ?? string.Empty; + // if (string.IsNullOrWhiteSpace(env)) + // { + // subject = $"{subject}"; + // } + // else + // { + // subject = $"({env}) {subject}"; + // } + // var mail = new ProjectReportEmailMongoDB + // { + // IsSent = false, + // Body = emailBody, + // Receivers = receivers, + // Subject = subject, + // }; + // await _cache.AddProjectReportMail(mail); + // } + // return Ok(ApiResponse.SuccessResponse("Project Report Mail is stored in MongoDB", "Project Report Mail is stored in MongoDB", 200)); + //} + + [HttpPost("add-report-mail")] + public async Task StoreProjectStatistics() { + Guid tenantId = _userHelper.GetTenantId(); - if (projectId == Guid.Empty) + // 1. Database-Side Grouping (Still the most efficient way to get initial data) + var projectMailGroups = await _context.MailDetails + .AsNoTracking() + .Where(m => m.TenantId == tenantId && m.ProjectId != Guid.Empty) + .GroupBy(m => new { m.ProjectId, m.MailListId }) + .Select(g => new + { + g.Key.ProjectId, + Recipients = g.Select(m => m.Recipient).Distinct().ToList(), + MailInfo = g.Select(m => new { Body = m.MailBody != null ? m.MailBody.Body : "", Subject = m.MailBody != null ? m.MailBody.Subject : "" }).FirstOrDefault() + }) + .ToListAsync(); + + if (!projectMailGroups.Any()) { - _logger.LogError("Provided empty project ID while fetching project report."); - return ApiResponse.ErrorResponse("Provided empty Project ID.", "Provided empty Project ID.", 400); + _logger.LogInfo("No project mail details found for tenant {TenantId} to process.", tenantId); + return Ok(ApiResponse.SuccessResponse("No project reports to generate.", "No project reports to generate.", 200)); } + string env = _configuration["environment:Title"] ?? string.Empty; - var statisticReport = await _reportHelper.GetDailyProjectReport(projectId, tenantId); - - if (statisticReport == null) + // 2. Process each group concurrently, but with isolated DBContexts. + var processingTasks = projectMailGroups.Select(async group => { - _logger.LogWarning("User attempted to fetch project progress for project ID {ProjectId} but not found.", projectId); - return ApiResponse.ErrorResponse("Project not found.", "Project not found.", 404); - } + // SOLUTION: Create a new DI scope for each parallel task. + using (var scope = _serviceScopeFactory.CreateScope()) + { + // Resolve services from this new, isolated scope. + // These helpers will get their own fresh DbContext instance. + var reportHelper = scope.ServiceProvider.GetRequiredService(); + var emailSender = scope.ServiceProvider.GetRequiredService(); + var cache = scope.ServiceProvider.GetRequiredService(); // e.g., IProjectReportCache - // Send Email - var emailBody = await _emailSender.SendProjectStatisticsEmail(recipientEmails, body, subject, statisticReport); - var employee = await _context.Employees.FirstOrDefaultAsync(e => e.Email != null && recipientEmails.Contains(e.Email)) ?? new Employee(); - - List mailLogs = new List(); - foreach (var recipientEmail in recipientEmails) - { - mailLogs.Add( - new MailLog + // The rest of the logic is the same, but now it's thread-safe. + try { - ProjectId = projectId, - EmailId = recipientEmail, - Body = emailBody, - EmployeeId = employee.Id, - TimeStamp = DateTime.UtcNow, - TenantId = tenantId - }); + var projectId = group.ProjectId; + var statisticReport = await reportHelper.GetDailyProjectReport(projectId, tenantId); + + if (statisticReport == null) + { + _logger.LogWarning("Statistic report for project ID {ProjectId} not found. Skipping.", projectId); + return; + } + + if (group.MailInfo == null) + { + _logger.LogWarning("MailBody info for project ID {ProjectId} not found. Skipping.", projectId); + return; + } + + var date = statisticReport.Date.ToString("dd-MMM-yyyy", CultureInfo.InvariantCulture); + // Assuming the first param to SendProjectStatisticsEmail was just a placeholder + var emailBody = await emailSender.SendProjectStatisticsEmail(new List(), group.MailInfo.Body, string.Empty, statisticReport); + + string subject = group.MailInfo.Subject + .Replace("{{DATE}}", date) + .Replace("{{PROJECT_NAME}}", statisticReport.ProjectName); + + subject = string.IsNullOrWhiteSpace(env) ? subject : $"({env}) {subject}"; + + var mail = new ProjectReportEmailMongoDB + { + IsSent = false, + Body = emailBody, + Receivers = group.Recipients, + Subject = subject, + }; + + await cache.AddProjectReportMail(mail); + } + catch (Exception ex) + { + // It's good practice to log any unexpected errors within a concurrent task. + _logger.LogError("Failed to process project report for ProjectId {ProjectId} : {Error}", group.ProjectId, ex.Message); + } + } + }); + + // Await all the concurrent, now thread-safe, tasks. + await Task.WhenAll(processingTasks); + + return Ok(ApiResponse.SuccessResponse( + $"{projectMailGroups.Count} Project Report Mail(s) are queued for storage.", + "Project Report Mail processing initiated.", + 200)); + } + + + [HttpGet("report-mail")] + public async Task GetProjectStatisticsFromCache() + { + var mailList = await _cache.GetProjectReportMail(false); + if (mailList == null) + { + return NotFound(ApiResponse.ErrorResponse("Not mail found", "Not mail found", 404)); } - _context.MailLogs.AddRange(mailLogs); - - await _context.SaveChangesAsync(); - return ApiResponse.SuccessResponse(statisticReport, "Email sent successfully", 200); + return Ok(ApiResponse.SuccessResponse(mailList, "Fetched list of mail body successfully", 200)); } } } diff --git a/Marco.Pms.Services/Service/EmailSender.cs b/Marco.Pms.Services/Service/EmailSender.cs index 568510a..4d66a4f 100644 --- a/Marco.Pms.Services/Service/EmailSender.cs +++ b/Marco.Pms.Services/Service/EmailSender.cs @@ -150,18 +150,24 @@ namespace MarcoBMS.Services.Service emailBody = emailBody.Replace("{{TEAM_ON_SITE}}", BuildTeamOnSiteHtml(report.TeamOnSite)); emailBody = emailBody.Replace("{{PERFORMED_TASK}}", BuildPerformedTaskHtml(report.PerformedTasks, report.Date)); emailBody = emailBody.Replace("{{PERFORMED_ATTENDANCE}}", BuildPerformedAttendanceHtml(report.PerformedAttendance)); - var subjectReplacements = new Dictionary + if (!string.IsNullOrWhiteSpace(subject)) { - {"DATE", date }, - {"PROJECT_NAME", report.ProjectName} - }; - foreach (var item in subjectReplacements) - { - subject = subject.Replace($"{{{{{item.Key}}}}}", item.Value); + var subjectReplacements = new Dictionary + { + {"DATE", date }, + {"PROJECT_NAME", report.ProjectName} + }; + foreach (var item in subjectReplacements) + { + subject = subject.Replace($"{{{{{item.Key}}}}}", item.Value); + } + string env = _configuration["environment:Title"] ?? string.Empty; + subject = CheckSubject(subject); + } + if (toEmails.Count > 0) + { + await SendEmailAsync(toEmails, subject, emailBody); } - string env = _configuration["environment:Title"] ?? string.Empty; - subject = CheckSubject(subject); - await SendEmailAsync(toEmails, subject, emailBody); return emailBody; } public async Task SendOTP(List toEmails, string emailBody, string name, string otp, string subject) -- 2.43.0 From 8bb8b3643f72cdf60da3c1bdef59326e9e15f504 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Sat, 12 Jul 2025 13:14:15 +0530 Subject: [PATCH 02/50] Refactored the function to add project in cache and added auto Mapper --- Marco.Pms.CacheHelper/ProjectCache.cs | 135 +----- .../Controllers/AttendanceController.cs | 28 +- .../Controllers/DashboardController.cs | 2 +- .../Controllers/EmployeeController.cs | 9 +- .../Controllers/ImageController.cs | 6 +- .../Controllers/ProjectController.cs | 286 ++++++++---- .../Helpers/CacheUpdateHelper.cs | 432 +++++++++++++++++- Marco.Pms.Services/Helpers/ProjectsHelper.cs | 77 +--- Marco.Pms.Services/Helpers/ReportHelper.cs | 99 +++- .../MappingProfiles/ProjectMappingProfile.cs | 30 ++ Marco.Pms.Services/Marco.Pms.Services.csproj | 1 + Marco.Pms.Services/Program.cs | 269 ++++++----- Marco.Pms.Services/Service/ILoggingService.cs | 5 +- Marco.Pms.Services/Service/LoggingServices.cs | 18 +- .../Service/PermissionServices.cs | 40 +- 15 files changed, 958 insertions(+), 479 deletions(-) create mode 100644 Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index 9b2036d..1fd36f4 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -24,132 +24,14 @@ namespace Marco.Pms.CacheHelper _projetCollection = mongoDB.GetCollection("ProjectDetails"); _taskCollection = mongoDB.GetCollection("WorkItemDetails"); } - public async Task AddProjectDetailsToCache(Project project) + + public async Task AddProjectDetailsToCache(ProjectMongoDB projectDetails) { - //_logger.LogInfo("[AddProjectDetails] Initiated for ProjectId: {ProjectId}", project.Id); - - var projectDetails = new ProjectMongoDB - { - Id = project.Id.ToString(), - Name = project.Name, - ShortName = project.ShortName, - ProjectAddress = project.ProjectAddress, - StartDate = project.StartDate, - EndDate = project.EndDate, - ContactPerson = project.ContactPerson - }; - - // Get project status - var status = await _context.StatusMasters - .AsNoTracking() - .FirstOrDefaultAsync(s => s.Id == project.ProjectStatusId); - - projectDetails.ProjectStatus = new StatusMasterMongoDB - { - Id = status?.Id.ToString(), - Status = status?.Status - }; - - // Get project team size - var teamSize = await _context.ProjectAllocations - .AsNoTracking() - .CountAsync(pa => pa.ProjectId == project.Id && pa.IsActive); - - projectDetails.TeamSize = teamSize; - - // Fetch related infrastructure in parallel - var buildings = await _context.Buildings - .AsNoTracking() - .Where(b => b.ProjectId == project.Id) - .ToListAsync(); - var buildingIds = buildings.Select(b => b.Id).ToList(); - - var floors = await _context.Floor - .AsNoTracking() - .Where(f => buildingIds.Contains(f.BuildingId)) - .ToListAsync(); - - var floorIds = floors.Select(f => f.Id).ToList(); - - var workAreas = await _context.WorkAreas - .AsNoTracking() - .Where(wa => floorIds.Contains(wa.FloorId)) - .ToListAsync(); - var workAreaIds = workAreas.Select(wa => wa.Id).ToList(); - - var workItems = await _context.WorkItems - .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) - .ToListAsync(); - - double totalPlannedWork = 0, totalCompletedWork = 0; - - var buildingMongoList = new List(); - - foreach (var building in buildings) - { - double buildingPlanned = 0, buildingCompleted = 0; - var buildingFloors = floors.Where(f => f.BuildingId == building.Id).ToList(); - - var floorMongoList = new List(); - foreach (var floor in buildingFloors) - { - double floorPlanned = 0, floorCompleted = 0; - var floorWorkAreas = workAreas.Where(wa => wa.FloorId == floor.Id).ToList(); - - var workAreaMongoList = new List(); - foreach (var wa in floorWorkAreas) - { - var items = workItems.Where(wi => wi.WorkAreaId == wa.Id).ToList(); - double waPlanned = items.Sum(wi => wi.PlannedWork); - double waCompleted = items.Sum(wi => wi.CompletedWork); - - workAreaMongoList.Add(new WorkAreaMongoDB - { - Id = wa.Id.ToString(), - FloorId = wa.FloorId.ToString(), - AreaName = wa.AreaName, - PlannedWork = waPlanned, - CompletedWork = waCompleted - }); - - floorPlanned += waPlanned; - floorCompleted += waCompleted; - } - - floorMongoList.Add(new FloorMongoDB - { - Id = floor.Id.ToString(), - BuildingId = floor.BuildingId.ToString(), - FloorName = floor.FloorName, - PlannedWork = floorPlanned, - CompletedWork = floorCompleted, - WorkAreas = workAreaMongoList - }); - - buildingPlanned += floorPlanned; - buildingCompleted += floorCompleted; - } - - buildingMongoList.Add(new BuildingMongoDB - { - Id = building.Id.ToString(), - ProjectId = building.ProjectId.ToString(), - BuildingName = building.Name, - Description = building.Description, - PlannedWork = buildingPlanned, - CompletedWork = buildingCompleted, - Floors = floorMongoList - }); - - totalPlannedWork += buildingPlanned; - totalCompletedWork += buildingCompleted; - } - - projectDetails.Buildings = buildingMongoList; - projectDetails.PlannedWork = totalPlannedWork; - projectDetails.CompletedWork = totalCompletedWork; - await _projetCollection.InsertOneAsync(projectDetails); + } + public async Task AddProjectDetailsListToCache(List projectDetailsList) + { + await _projetCollection.InsertManyAsync(projectDetailsList); //_logger.LogInfo("[AddProjectDetails] Project details inserted in MongoDB for ProjectId: {ProjectId}", project.Id); } public async Task UpdateProjectDetailsOnlyToCache(Project project) @@ -218,7 +100,7 @@ namespace Marco.Pms.CacheHelper //_logger.LogInfo("Successfully fetched project details (excluding Buildings) for ProjectId: {ProjectId}", projectId); return project; } - public async Task?> GetProjectDetailsListFromCache(List projectIds) + public async Task> GetProjectDetailsListFromCache(List projectIds) { List stringProjectIds = projectIds.Select(p => p.ToString()).ToList(); var filter = Builders.Filter.In(p => p.Id, stringProjectIds); @@ -229,6 +111,9 @@ namespace Marco.Pms.CacheHelper .ToListAsync(); return projects; } + + // ------------------------------------------------------- Project InfraStructure ------------------------------------------------------- + public async Task AddBuildngInfraToCache(Guid projectId, Building? building, Floor? floor, WorkArea? workArea, Guid? buildingId) { var stringProjectId = projectId.ToString(); diff --git a/Marco.Pms.Services/Controllers/AttendanceController.cs b/Marco.Pms.Services/Controllers/AttendanceController.cs index 2622323..4c2f2c1 100644 --- a/Marco.Pms.Services/Controllers/AttendanceController.cs +++ b/Marco.Pms.Services/Controllers/AttendanceController.cs @@ -1,8 +1,8 @@ -using System.Globalization; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.AttendanceModule; using Marco.Pms.Model.Dtos.Attendance; using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; +using System.Globalization; using Document = Marco.Pms.Model.DocumentManager.Document; namespace MarcoBMS.Services.Controllers @@ -61,7 +62,13 @@ namespace MarcoBMS.Services.Controllers { Guid TenantId = GetTenantId(); - List lstAttendance = await _context.AttendanceLogs.Include(a => a.Document).Include(a => a.Employee).Include(a => a.UpdatedByEmployee).Where(c => c.AttendanceId == attendanceid && c.TenantId == TenantId).ToListAsync(); + List lstAttendance = await _context.AttendanceLogs + .Include(a => a.Document) + .Include(a => a.Employee) + .Include(a => a.UpdatedByEmployee) + .Where(c => c.AttendanceId == attendanceid && c.TenantId == TenantId) + .ToListAsync(); + List attendanceLogVMs = new List(); foreach (var attendanceLog in lstAttendance) { @@ -139,9 +146,9 @@ namespace MarcoBMS.Services.Controllers { Guid TenantId = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var hasTeamAttendancePermission = await _permission.HasPermission(new Guid("915e6bff-65f6-4e3f-aea8-3fd217d3ea9e"), LoggedInEmployee.Id); - var hasSelfAttendancePermission = await _permission.HasPermission(new Guid("ccb0589f-712b-43de-92ed-5b6088e7dc4e"), LoggedInEmployee.Id); - var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId.ToString()); + var hasTeamAttendancePermission = await _permission.HasPermission(PermissionsMaster.TeamAttendance, LoggedInEmployee.Id); + var hasSelfAttendancePermission = await _permission.HasPermission(PermissionsMaster.SelfAttendance, LoggedInEmployee.Id); + var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId); if (!hasProjectPermission) { @@ -255,9 +262,9 @@ namespace MarcoBMS.Services.Controllers { Guid TenantId = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var hasTeamAttendancePermission = await _permission.HasPermission(new Guid("915e6bff-65f6-4e3f-aea8-3fd217d3ea9e"), LoggedInEmployee.Id); - var hasSelfAttendancePermission = await _permission.HasPermission(new Guid("ccb0589f-712b-43de-92ed-5b6088e7dc4e"), LoggedInEmployee.Id); - var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId.ToString()); + var hasTeamAttendancePermission = await _permission.HasPermission(PermissionsMaster.TeamAttendance, LoggedInEmployee.Id); + var hasSelfAttendancePermission = await _permission.HasPermission(PermissionsMaster.SelfAttendance, LoggedInEmployee.Id); + var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId); if (!hasProjectPermission) { @@ -361,7 +368,7 @@ namespace MarcoBMS.Services.Controllers Guid TenantId = GetTenantId(); Employee LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var result = new List(); - var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId.ToString()); + var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId); if (!hasProjectPermission) { @@ -371,7 +378,6 @@ namespace MarcoBMS.Services.Controllers List lstAttendance = await _context.Attendes.Where(c => c.ProjectID == projectId && c.Activity == ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE && c.TenantId == TenantId).ToListAsync(); - List projectteam = await _projectsHelper.GetTeamByProject(TenantId, projectId, true); var idList = projectteam.Select(p => p.EmployeeId).ToList(); var jobRole = await _context.JobRoles.ToListAsync(); diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index 8ed0ba0..bdb965c 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -373,7 +373,7 @@ namespace Marco.Pms.Services.Controllers // Step 2: Permission check var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - bool hasAssigned = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.ToString()); + bool hasAssigned = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId); if (!hasAssigned) { diff --git a/Marco.Pms.Services/Controllers/EmployeeController.cs b/Marco.Pms.Services/Controllers/EmployeeController.cs index 9884e53..2f0ca5e 100644 --- a/Marco.Pms.Services/Controllers/EmployeeController.cs +++ b/Marco.Pms.Services/Controllers/EmployeeController.cs @@ -1,6 +1,4 @@ -using System.Data; -using System.Net; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Attendance; using Marco.Pms.Model.Dtos.Employees; using Marco.Pms.Model.Employees; @@ -18,6 +16,8 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; +using System.Data; +using System.Net; namespace MarcoBMS.Services.Controllers { @@ -119,8 +119,7 @@ namespace MarcoBMS.Services.Controllers loggedInEmployee.Id, projectid ?? Guid.Empty, ShowInactive); // Step 3: Fetch project access and permissions - List projects = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - var projectIds = projects.Select(p => p.Id).ToList(); + var projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); var hasViewAllEmployeesPermission = await _permission.HasPermission(PermissionsMaster.ViewAllEmployees, loggedInEmployee.Id); var hasViewTeamMembersPermission = await _permission.HasPermission(PermissionsMaster.ViewTeamMembers, loggedInEmployee.Id); diff --git a/Marco.Pms.Services/Controllers/ImageController.cs b/Marco.Pms.Services/Controllers/ImageController.cs index 48fbc3b..9014171 100644 --- a/Marco.Pms.Services/Controllers/ImageController.cs +++ b/Marco.Pms.Services/Controllers/ImageController.cs @@ -1,5 +1,4 @@ -using System.Text.Json; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Activities; using Marco.Pms.Model.Dtos.DocumentManager; using Marco.Pms.Model.Employees; @@ -13,6 +12,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; +using System.Text.Json; namespace Marco.Pms.Services.Controllers { @@ -54,7 +54,7 @@ namespace Marco.Pms.Services.Controllers } // Step 2: Check project access permission - var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.ToString()); + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); if (!hasPermission) { _logger.LogWarning("[GetImageList] Access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 07ddbfd..29f9d04 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,10 +1,10 @@ -using Marco.Pms.DataAccess.Data; +using AutoMapper; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Activities; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Mapper; -using Marco.Pms.Model.Master; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; @@ -36,16 +36,12 @@ namespace MarcoBMS.Services.Controllers private readonly IHubContext _signalR; private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; - private readonly IServiceScopeFactory _serviceScopeFactory; - private readonly Guid ViewProjects; - private readonly Guid ManageProject; - private readonly Guid ViewInfra; - private readonly Guid ManageInfra; + private readonly IMapper _mapper; private readonly Guid tenantId; public ProjectController(ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, RolesHelper rolesHelper, ProjectsHelper projectHelper, - IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IServiceScopeFactory serviceScopeFactory) + IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IMapper mapper) { _context = context; _userHelper = userHelper; @@ -55,16 +51,12 @@ namespace MarcoBMS.Services.Controllers _signalR = signalR; _cache = cache; _permission = permission; - ViewProjects = Guid.Parse("6ea44136-987e-44ba-9e5d-1cf8f5837ebc"); - ManageProject = Guid.Parse("172fc9b6-755b-4f62-ab26-55c34a330614"); - ViewInfra = Guid.Parse("8d7cc6e3-9147-41f7-aaa7-fa507e450bd4"); - ManageInfra = Guid.Parse("f2aee20a-b754-4537-8166-f9507b44585b"); + _mapper = mapper; tenantId = _userHelper.GetTenantId(); - _serviceScopeFactory = serviceScopeFactory; } - [HttpGet("list/basic")] - public async Task GetAllProjects() + [HttpGet("list/basic1")] + public async Task GetAllProjects1() { if (!ModelState.IsValid) { @@ -84,31 +76,113 @@ namespace MarcoBMS.Services.Controllers return Unauthorized(ApiResponse.ErrorResponse("Employee not found.", null, 401)); } + List response = new List(); + List projectIds = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - List projects = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); + List? projectsDetails = await _cache.GetProjectDetailsList(projectIds); + if (projectsDetails == null) + { + List projects = await _context.Projects.Where(p => projectIds.Contains(p.Id)).ToListAsync(); + //using (var scope = _serviceScopeFactory.CreateScope()) + //{ + // var cacheHelper = scope.ServiceProvider.GetRequiredService(); - - // 4. Project projection to ProjectInfoVM - // This part is already quite efficient. - // Ensure ToProjectInfoVMFromProject() is also optimized and doesn't perform N+1 queries. - // If ProjectInfoVM only needs a subset of Project properties, - // you can use a LINQ Select directly on the IQueryable before ToListAsync() - // to fetch only the required columns from the database. - List response = projects - .Select(project => project.ToProjectInfoVMFromProject()) - .ToList(); - - - //List response = new List(); - - //foreach (var project in projects) - //{ - // response.Add(project.ToProjectInfoVMFromProject()); - //} + //} + foreach (var project in projects) + { + await _cache.AddProjectDetails(project); + } + response = projects.Select(p => _mapper.Map(p)).ToList(); + } + else + { + response = projectsDetails.Select(p => _mapper.Map(p)).ToList(); + } return Ok(ApiResponse.SuccessResponse(response, "Success.", 200)); } + [HttpGet("list/basic")] + public async Task GetAllProjects() // Renamed for clarity + { + // Step 1: Get the current user + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + if (loggedInEmployee == null) + { + return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "User could not be identified.", 401)); + } + + _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); + + // Step 2: Get the list of project IDs the user has access to + Guid tenantId = _userHelper.GetTenantId(); // Assuming this is still needed by the helper + List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + + if (accessibleProjectIds == null || !accessibleProjectIds.Any()) + { + _logger.LogInfo("No accessible projects found for EmployeeId {EmployeeId}", loggedInEmployee.Id); + return Ok(ApiResponse>.SuccessResponse(new List(), "Success.", 200)); + } + + // Step 3: Fetch project ViewModels using the optimized, cache-aware helper + var projectVMs = await GetProjectInfosByIdsAsync(accessibleProjectIds); + + // Step 4: Return the final list + _logger.LogInfo("Successfully returned {ProjectCount} projects for EmployeeId {EmployeeId}", projectVMs.Count, loggedInEmployee.Id); + return Ok(ApiResponse>.SuccessResponse(projectVMs, $"{projectVMs.Count} records of project fetchd successfully", 200)); + } + + /// + /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. + /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the + /// database (as Project), updates the cache, and returns a unified list of ViewModels. + /// + /// The list of project IDs to retrieve. + /// A list of ProjectInfoVMs. + private async Task> GetProjectInfosByIdsAsync(List projectIds) + { + // --- Step 1: Fetch from Cache --- + // The cache returns a list of MongoDB documents for the projects it found. + var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var finalViewModels = _mapper.Map>(cachedMongoDocs); + + _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); + + // --- Step 2: Identify Missing Projects --- + // If we found everything in the cache, we can return early. + if (finalViewModels.Count == projectIds.Count) + { + return finalViewModels; + } + + var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id + var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); + + // --- Step 3: Fetch Missing from Database --- + if (missingIds.Any()) + { + _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); + + var projectsFromDb = await _context.Projects + .Where(p => missingIds.Contains(p.Id)) + .AsNoTracking() // Use AsNoTracking for read-only query performance + .ToListAsync(); + + if (projectsFromDb.Any()) + { + // Map the newly fetched projects (from SQL) to their ViewModel + var vmsFromDb = _mapper.Map>(projectsFromDb); + finalViewModels.AddRange(vmsFromDb); + + // --- Step 4: Update Cache with Missing Items in a new scope --- + _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); + await _cache.AddProjectDetailsList(projectsFromDb); + } + } + + return finalViewModels; + } + [HttpGet("list")] public async Task GetAll() { @@ -139,39 +213,63 @@ namespace MarcoBMS.Services.Controllers // projects = await _context.Projects.Where(c => projectsId.Contains(c.Id.ToString()) && c.TenantId == tenantId).ToListAsync(); //} - List projects = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - - - - + //List projects = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); + ////List projects = new List(); + /// List response = new List(); - foreach (var project in projects) + List projectIds = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); + + var projectsDetails = await _cache.GetProjectDetailsList(projectIds); + if (projectsDetails == null) { - var result = project.ToProjectListVMFromProject(); - var team = await _context.ProjectAllocations.Where(p => p.TenantId == tenantId && p.ProjectId == project.Id && p.IsActive == true).ToListAsync(); + List projects = await _context.Projects.Where(p => projectIds.Contains(p.Id)).ToListAsync(); - result.TeamSize = team.Count(); + var teams = await _context.ProjectAllocations.Where(p => p.TenantId == tenantId && projectIds.Contains(p.ProjectId) && p.IsActive == true).ToListAsync(); - List buildings = await _context.Buildings.Where(b => b.ProjectId == project.Id && b.TenantId == tenantId).ToListAsync(); - List idList = buildings.Select(b => b.Id).ToList(); - List floors = await _context.Floor.Where(f => idList.Contains(f.BuildingId) && f.TenantId == tenantId).ToListAsync(); - idList = floors.Select(f => f.Id).ToList(); + List allBuildings = await _context.Buildings.Where(b => projectIds.Contains(b.ProjectId) && b.TenantId == tenantId).ToListAsync(); + List idList = allBuildings.Select(b => b.Id).ToList(); - List workAreas = await _context.WorkAreas.Where(a => idList.Contains(a.FloorId) && a.TenantId == tenantId).ToListAsync(); - idList = workAreas.Select(a => a.Id).ToList(); + List allFloors = await _context.Floor.Where(f => idList.Contains(f.BuildingId) && f.TenantId == tenantId).ToListAsync(); + idList = allFloors.Select(f => f.Id).ToList(); - List workItems = await _context.WorkItems.Where(i => idList.Contains(i.WorkAreaId) && i.TenantId == tenantId).Include(i => i.ActivityMaster).ToListAsync(); - double completedTask = 0; - double plannedTask = 0; - foreach (var workItem in workItems) + List allWorkAreas = await _context.WorkAreas.Where(a => idList.Contains(a.FloorId) && a.TenantId == tenantId).ToListAsync(); + idList = allWorkAreas.Select(a => a.Id).ToList(); + + List allWorkItems = await _context.WorkItems.Where(i => idList.Contains(i.WorkAreaId) && i.TenantId == tenantId).Include(i => i.ActivityMaster).ToListAsync(); + + foreach (var project in projects) { - completedTask += workItem.CompletedWork; - plannedTask += workItem.PlannedWork; + var result = _mapper.Map(project); + var team = teams.Where(p => p.TenantId == tenantId && p.ProjectId == project.Id && p.IsActive == true).ToList(); + + result.TeamSize = team.Count(); + + List buildings = allBuildings.Where(b => b.ProjectId == project.Id && b.TenantId == tenantId).ToList(); + idList = buildings.Select(b => b.Id).ToList(); + + List floors = allFloors.Where(f => idList.Contains(f.BuildingId) && f.TenantId == tenantId).ToList(); + idList = floors.Select(f => f.Id).ToList(); + + List workAreas = allWorkAreas.Where(a => idList.Contains(a.FloorId) && a.TenantId == tenantId).ToList(); + idList = workAreas.Select(a => a.Id).ToList(); + + List workItems = allWorkItems.Where(i => idList.Contains(i.WorkAreaId) && i.TenantId == tenantId).ToList(); + double completedTask = 0; + double plannedTask = 0; + foreach (var workItem in workItems) + { + completedTask += workItem.CompletedWork; + plannedTask += workItem.PlannedWork; + } + result.PlannedWork = plannedTask; + result.CompletedWork = completedTask; + response.Add(result); } - result.PlannedWork = plannedTask; - result.CompletedWork = completedTask; - response.Add(result); + } + else + { + response = projectsDetails.Select(p => _mapper.Map(p)).ToList(); } return Ok(ApiResponse.SuccessResponse(response, "Success.", 200)); @@ -215,7 +313,7 @@ namespace MarcoBMS.Services.Controllers _logger.LogInfo("Details requested by EmployeeId: {EmployeeId} for ProjectId: {ProjectId}", loggedInEmployee.Id, id); // Step 3: Check global view project permission - var hasViewProjectPermission = await _permission.HasPermission(ViewProjects, loggedInEmployee.Id); + var hasViewProjectPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); if (!hasViewProjectPermission) { _logger.LogWarning("ViewProjects permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); @@ -223,7 +321,7 @@ namespace MarcoBMS.Services.Controllers } // Step 4: Check permission for this specific project - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, id.ToString()); + var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, id); if (!hasProjectPermission) { _logger.LogWarning("Project-specific access denied. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployee.Id, id); @@ -238,7 +336,9 @@ namespace MarcoBMS.Services.Controllers var project = await _context.Projects .Include(c => c.ProjectStatus) .FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id); - projectVM = GetProjectViewModel(project); + + projectVM = _mapper.Map(project); + if (project != null) { await _cache.AddProjectDetails(project); @@ -246,23 +346,28 @@ namespace MarcoBMS.Services.Controllers } else { - projectVM = new ProjectVM + projectVM = _mapper.Map(projectDetails); + if (projectVM.ProjectStatus != null) { - Id = projectDetails.Id != null ? Guid.Parse(projectDetails.Id) : Guid.Empty, - Name = projectDetails.Name, - ShortName = projectDetails.ShortName, - ProjectAddress = projectDetails.ProjectAddress, - StartDate = projectDetails.StartDate, - EndDate = projectDetails.EndDate, - ContactPerson = projectDetails.ContactPerson, - ProjectStatus = new StatusMaster - { - Id = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, - Status = projectDetails.ProjectStatus?.Status, - TenantId = tenantId - } - //ProjectStatusId = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, - }; + projectVM.ProjectStatus.TenantId = tenantId; + } + //projectVM = new ProjectVM + //{ + // Id = projectDetails.Id != null ? Guid.Parse(projectDetails.Id) : Guid.Empty, + // Name = projectDetails.Name, + // ShortName = projectDetails.ShortName, + // ProjectAddress = projectDetails.ProjectAddress, + // StartDate = projectDetails.StartDate, + // EndDate = projectDetails.EndDate, + // ContactPerson = projectDetails.ContactPerson, + // ProjectStatus = new StatusMaster + // { + // Id = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, + // Status = projectDetails.ProjectStatus?.Status, + // TenantId = tenantId + // } + // //ProjectStatusId = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, + //}; } if (projectVM == null) @@ -277,25 +382,6 @@ namespace MarcoBMS.Services.Controllers return Ok(ApiResponse.SuccessResponse(projectVM, "Project details fetched successfully", 200)); } - private ProjectVM? GetProjectViewModel(Project? project) - { - if (project == null) - { - return null; - } - return new ProjectVM - { - Id = project.Id, - Name = project.Name, - ShortName = project.ShortName, - StartDate = project.StartDate, - EndDate = project.EndDate, - ProjectStatus = project.ProjectStatus, - ContactPerson = project.ContactPerson, - ProjectAddress = project.ProjectAddress, - }; - } - [HttpGet("details-old/{id}")] public async Task DetailsOld([FromRoute] Guid id) { @@ -470,7 +556,7 @@ namespace MarcoBMS.Services.Controllers { // These operations do not depend on each other, so they can run in parallel. Task cacheAddDetailsTask = _cache.AddProjectDetails(project); - Task cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(ManageProject); + Task cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(PermissionsMaster.ManageProject); var notification = new { LoggedInUserId = loggedInUserId, Keyword = "Create_Project", Response = project.ToProjectDto() }; // Send notification only to the relevant group (e.g., users in the same tenant) @@ -762,7 +848,7 @@ namespace MarcoBMS.Services.Controllers var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 2: Check project-specific permission - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.ToString()); + var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); if (!hasProjectPermission) { _logger.LogWarning("Project access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); @@ -770,7 +856,7 @@ namespace MarcoBMS.Services.Controllers } // Step 3: Check 'ViewInfra' permission - var hasViewInfraPermission = await _permission.HasPermission(ViewInfra, loggedInEmployee.Id); + var hasViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); if (!hasViewInfraPermission) { _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); @@ -883,7 +969,7 @@ namespace MarcoBMS.Services.Controllers var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 2: Check if the employee has ViewInfra permission - var hasViewInfraPermission = await _permission.HasPermission(ViewInfra, loggedInEmployee.Id); + var hasViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); if (!hasViewInfraPermission) { _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index ae6264e..589ab52 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -1,7 +1,9 @@ using Marco.Pms.CacheHelper; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using MarcoBMS.Services.Service; +using Microsoft.EntityFrameworkCore; using Project = Marco.Pms.Model.Projects.Project; namespace Marco.Pms.Services.Helpers @@ -10,25 +12,407 @@ namespace Marco.Pms.Services.Helpers { private readonly ProjectCache _projectCache; private readonly EmployeeCache _employeeCache; + private readonly ReportCache _reportCache; private readonly ILoggingService _logger; + private readonly IDbContextFactory _dbContextFactory; - public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ILoggingService logger) + public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ReportCache reportCache, ILoggingService logger, + IDbContextFactory dbContextFactory) { _projectCache = projectCache; _employeeCache = employeeCache; + _reportCache = reportCache; _logger = logger; + _dbContextFactory = dbContextFactory; } - // ------------------------------------ Project Details and Infrastructure Cache --------------------------------------- + // ------------------------------------ Project Details Cache --------------------------------------- + // Assuming you have access to an IDbContextFactory as _dbContextFactory + // This is crucial for safe parallel database operations. + public async Task AddProjectDetails(Project project) { + // --- Step 1: Fetch all required data from the database in parallel --- + + // Each task uses its own DbContext instance to avoid concurrency issues. + var statusTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.StatusMasters + .AsNoTracking() + .Where(s => s.Id == project.ProjectStatusId) + .Select(s => new { s.Id, s.Status }) // Projection + .FirstOrDefaultAsync(); + }); + + var teamSizeTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.ProjectAllocations + .AsNoTracking() + .CountAsync(pa => pa.ProjectId == project.Id && pa.IsActive); // Server-side count is efficient + }); + + // This task fetches the entire infrastructure hierarchy and performs aggregations in the database. + var infrastructureTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + + // 1. Fetch all hierarchical data using projections. + // This is still a chain, but it's inside one task and much faster due to projections. + var buildings = await context.Buildings.AsNoTracking() + .Where(b => b.ProjectId == project.Id) + .Select(b => new { b.Id, b.ProjectId, b.Name, b.Description }) + .ToListAsync(); + var buildingIds = buildings.Select(b => b.Id).ToList(); + + var floors = await context.Floor.AsNoTracking() + .Where(f => buildingIds.Contains(f.BuildingId)) + .Select(f => new { f.Id, f.BuildingId, f.FloorName }) + .ToListAsync(); + var floorIds = floors.Select(f => f.Id).ToList(); + + var workAreas = await context.WorkAreas.AsNoTracking() + .Where(wa => floorIds.Contains(wa.FloorId)) + .Select(wa => new { wa.Id, wa.FloorId, wa.AreaName }) + .ToListAsync(); + var workAreaIds = workAreas.Select(wa => wa.Id).ToList(); + + // 2. THE KEY OPTIMIZATION: Aggregate work items in the database. + var workSummaries = await context.WorkItems.AsNoTracking() + .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) + .GroupBy(wi => wi.WorkAreaId) // Group by parent on the DB server + .Select(g => new // Let the DB do the SUM + { + WorkAreaId = g.Key, + PlannedWork = g.Sum(i => i.PlannedWork), + CompletedWork = g.Sum(i => i.CompletedWork) + }) + .ToDictionaryAsync(x => x.WorkAreaId); // Return a ready-to-use dictionary + + return (buildings, floors, workAreas, workSummaries); + }); + + // Wait for all parallel database operations to complete. + await Task.WhenAll(statusTask, teamSizeTask, infrastructureTask); + + // Get the results from the completed tasks. + var status = await statusTask; + var teamSize = await teamSizeTask; + var (allBuildings, allFloors, allWorkAreas, workSummariesByWorkAreaId) = await infrastructureTask; + + // --- Step 2: Process the fetched data and build the MongoDB model --- + + var projectDetails = new ProjectMongoDB + { + Id = project.Id.ToString(), + Name = project.Name, + ShortName = project.ShortName, + ProjectAddress = project.ProjectAddress, + StartDate = project.StartDate, + EndDate = project.EndDate, + ContactPerson = project.ContactPerson, + TeamSize = teamSize + }; + + projectDetails.ProjectStatus = new StatusMasterMongoDB + { + Id = status?.Id.ToString(), + Status = status?.Status + }; + + // Use fast in-memory lookups instead of .Where() in loops. + var floorsByBuildingId = allFloors.ToLookup(f => f.BuildingId); + var workAreasByFloorId = allWorkAreas.ToLookup(wa => wa.FloorId); + + double totalPlannedWork = 0, totalCompletedWork = 0; + var buildingMongoList = new List(); + + foreach (var building in allBuildings) + { + double buildingPlanned = 0, buildingCompleted = 0; + var floorMongoList = new List(); + + foreach (var floor in floorsByBuildingId[building.Id]) // Fast lookup + { + double floorPlanned = 0, floorCompleted = 0; + var workAreaMongoList = new List(); + + foreach (var wa in workAreasByFloorId[floor.Id]) // Fast lookup + { + // Get the pre-calculated summary from the dictionary. O(1) operation. + workSummariesByWorkAreaId.TryGetValue(wa.Id, out var summary); + var waPlanned = summary?.PlannedWork ?? 0; + var waCompleted = summary?.CompletedWork ?? 0; + + workAreaMongoList.Add(new WorkAreaMongoDB + { + Id = wa.Id.ToString(), + FloorId = wa.FloorId.ToString(), + AreaName = wa.AreaName, + PlannedWork = waPlanned, + CompletedWork = waCompleted + }); + + floorPlanned += waPlanned; + floorCompleted += waCompleted; + } + + floorMongoList.Add(new FloorMongoDB + { + Id = floor.Id.ToString(), + BuildingId = floor.BuildingId.ToString(), + FloorName = floor.FloorName, + PlannedWork = floorPlanned, + CompletedWork = floorCompleted, + WorkAreas = workAreaMongoList + }); + + buildingPlanned += floorPlanned; + buildingCompleted += floorCompleted; + } + + buildingMongoList.Add(new BuildingMongoDB + { + Id = building.Id.ToString(), + ProjectId = building.ProjectId.ToString(), + BuildingName = building.Name, + Description = building.Description, + PlannedWork = buildingPlanned, + CompletedWork = buildingCompleted, + Floors = floorMongoList + }); + + totalPlannedWork += buildingPlanned; + totalCompletedWork += buildingCompleted; + } + + projectDetails.Buildings = buildingMongoList; + projectDetails.PlannedWork = totalPlannedWork; + projectDetails.CompletedWork = totalCompletedWork; + try { - await _projectCache.AddProjectDetailsToCache(project); + await _projectCache.AddProjectDetailsToCache(projectDetails); } catch (Exception ex) { - _logger.LogWarning("Error occured while adding project {ProjectId} to Cache : {Error}", project.Id, ex.Message); + _logger.LogWarning("Error occurred while adding project {ProjectId} to Cache: {Error}", project.Id, ex.Message); + } + } + public async Task AddProjectDetailsList(List projects) + { + var projectIds = projects.Select(p => p.Id).ToList(); + if (!projectIds.Any()) + { + return; // Nothing to do + } + var projectStatusIds = projects.Select(p => p.ProjectStatusId).Distinct().ToList(); + + // --- Step 1: Fetch all required data in maximum parallel --- + // Each task uses its own DbContext and selects only the required columns (projection). + + var statusTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.StatusMasters + .AsNoTracking() + .Where(s => projectStatusIds.Contains(s.Id)) + .Select(s => new { s.Id, s.Status }) // Projection + .ToDictionaryAsync(s => s.Id); + }); + + var teamSizeTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + // Server-side aggregation and projection into a dictionary + return await context.ProjectAllocations + .AsNoTracking() + .Where(pa => projectIds.Contains(pa.ProjectId) && pa.IsActive) + .GroupBy(pa => pa.ProjectId) + .Select(g => new { ProjectId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ProjectId, x => x.Count); + }); + + var buildingsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Buildings + .AsNoTracking() + .Where(b => projectIds.Contains(b.ProjectId)) + .Select(b => new { b.Id, b.ProjectId, b.Name, b.Description }) // Projection + .ToListAsync(); + }); + + // We need the building IDs for the next level, so we must await this one first. + var allBuildings = await buildingsTask; + var buildingIds = allBuildings.Select(b => b.Id).ToList(); + + var floorsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Floor + .AsNoTracking() + .Where(f => buildingIds.Contains(f.BuildingId)) + .Select(f => new { f.Id, f.BuildingId, f.FloorName }) // Projection + .ToListAsync(); + }); + + // We need floor IDs for the next level. + var allFloors = await floorsTask; + var floorIds = allFloors.Select(f => f.Id).ToList(); + + var workAreasTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.WorkAreas + .AsNoTracking() + .Where(wa => floorIds.Contains(wa.FloorId)) + .Select(wa => new { wa.Id, wa.FloorId, wa.AreaName }) // Projection + .ToListAsync(); + }); + + // The most powerful optimization: Aggregate work items in the database. + var workSummaryTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + var workAreaIds = await context.WorkAreas + .Where(wa => floorIds.Contains(wa.FloorId)) + .Select(wa => wa.Id) + .ToListAsync(); + + // Let the DB do the SUM. This is much faster and transfers less data. + return await context.WorkItems + .AsNoTracking() + .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) + .GroupBy(wi => wi.WorkAreaId) + .Select(g => new + { + WorkAreaId = g.Key, + PlannedWork = g.Sum(wi => wi.PlannedWork), + CompletedWork = g.Sum(wi => wi.CompletedWork) + }) + .ToDictionaryAsync(x => x.WorkAreaId); + }); + + // Await the remaining parallel tasks. + await Task.WhenAll(statusTask, teamSizeTask, workAreasTask, workSummaryTask); + + // --- Step 2: Process the fetched data and build the MongoDB models --- + + var allStatuses = await statusTask; + var teamSizesByProjectId = await teamSizeTask; + var allWorkAreas = await workAreasTask; + var workSummariesByWorkAreaId = await workSummaryTask; + + // Create fast in-memory lookups for hierarchical data + var buildingsByProjectId = allBuildings.ToLookup(b => b.ProjectId); + var floorsByBuildingId = allFloors.ToLookup(f => f.BuildingId); + var workAreasByFloorId = allWorkAreas.ToLookup(wa => wa.FloorId); + + var projectDetailsList = new List(projects.Count); + foreach (var project in projects) + { + var projectDetails = new ProjectMongoDB + { + Id = project.Id.ToString(), + Name = project.Name, + ShortName = project.ShortName, + ProjectAddress = project.ProjectAddress, + StartDate = project.StartDate, + EndDate = project.EndDate, + ContactPerson = project.ContactPerson, + TeamSize = teamSizesByProjectId.GetValueOrDefault(project.Id, 0) + }; + + if (allStatuses.TryGetValue(project.ProjectStatusId, out var status)) + { + projectDetails.ProjectStatus = new StatusMasterMongoDB + { + Id = status.Id.ToString(), + Status = status.Status + }; + } + + double totalPlannedWork = 0, totalCompletedWork = 0; + var buildingMongoList = new List(); + + foreach (var building in buildingsByProjectId[project.Id]) + { + double buildingPlanned = 0, buildingCompleted = 0; + var floorMongoList = new List(); + + foreach (var floor in floorsByBuildingId[building.Id]) + { + double floorPlanned = 0, floorCompleted = 0; + var workAreaMongoList = new List(); + + foreach (var wa in workAreasByFloorId[floor.Id]) + { + double waPlanned = 0, waCompleted = 0; + if (workSummariesByWorkAreaId.TryGetValue(wa.Id, out var summary)) + { + waPlanned = summary.PlannedWork; + waCompleted = summary.CompletedWork; + } + + workAreaMongoList.Add(new WorkAreaMongoDB + { + Id = wa.Id.ToString(), + FloorId = wa.FloorId.ToString(), + AreaName = wa.AreaName, + PlannedWork = waPlanned, + CompletedWork = waCompleted + }); + + floorPlanned += waPlanned; + floorCompleted += waCompleted; + } + + floorMongoList.Add(new FloorMongoDB + { + Id = floor.Id.ToString(), + BuildingId = floor.BuildingId.ToString(), + FloorName = floor.FloorName, + PlannedWork = floorPlanned, + CompletedWork = floorCompleted, + WorkAreas = workAreaMongoList + }); + + buildingPlanned += floorPlanned; + buildingCompleted += floorCompleted; + } + + buildingMongoList.Add(new BuildingMongoDB + { + Id = building.Id.ToString(), + ProjectId = building.ProjectId.ToString(), + BuildingName = building.Name, + Description = building.Description, + PlannedWork = buildingPlanned, + CompletedWork = buildingCompleted, + Floors = floorMongoList + }); + + totalPlannedWork += buildingPlanned; + totalCompletedWork += buildingCompleted; + } + + projectDetails.Buildings = buildingMongoList; + projectDetails.PlannedWork = totalPlannedWork; + projectDetails.CompletedWork = totalCompletedWork; + + projectDetailsList.Add(projectDetails); + } + + // --- Step 3: Update the cache --- + try + { + await _projectCache.AddProjectDetailsListToCache(projectDetailsList); + } + catch (Exception ex) + { + _logger.LogWarning("Error occurred while adding project list to Cache: {Error}", ex.Message); } } public async Task UpdateProjectDetailsOnly(Project project) @@ -62,7 +446,14 @@ namespace Marco.Pms.Services.Helpers try { var response = await _projectCache.GetProjectDetailsListFromCache(projectIds); - return response; + if (response.Any()) + { + return response; + } + else + { + return null; + } } catch (Exception ex) { @@ -70,6 +461,9 @@ namespace Marco.Pms.Services.Helpers return null; } } + + // ------------------------------------ Project Infrastructure Cache --------------------------------------- + public async Task AddBuildngInfra(Guid projectId, Building? building = null, Floor? floor = null, WorkArea? workArea = null, Guid? buildingId = null) { try @@ -342,5 +736,33 @@ namespace Marco.Pms.Services.Helpers _logger.LogWarning("Error occured while deleting Application role {RoleId} from Cache for employee {EmployeeId}: {Error}", roleId, employeeId, ex.Message); } } + + + // ------------------------------------ Report Cache --------------------------------------- + + public async Task?> GetProjectReportMail(bool IsSend) + { + try + { + var response = await _reportCache.GetProjectReportMailFromCache(IsSend); + return response; + } + catch (Exception ex) + { + _logger.LogError("Error occured while fetching project report mail bodys: {Error}", ex.Message); + return null; + } + } + public async Task AddProjectReportMail(ProjectReportEmailMongoDB report) + { + try + { + await _reportCache.AddProjectReportMailToCache(report); + } + catch (Exception ex) + { + _logger.LogError("Error occured while adding project report mail bodys: {Error}", ex.Message); + } + } } } diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs index 85003ae..fb5b6f2 100644 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ b/Marco.Pms.Services/Helpers/ProjectsHelper.cs @@ -1,9 +1,9 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; -using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using Marco.Pms.Services.Helpers; +using Marco.Pms.Services.Service; using Microsoft.EntityFrameworkCore; namespace MarcoBMS.Services.Helpers @@ -13,13 +13,14 @@ namespace MarcoBMS.Services.Helpers private readonly ApplicationDbContext _context; private readonly RolesHelper _rolesHelper; private readonly CacheUpdateHelper _cache; + private readonly PermissionServices _permission; - - public ProjectsHelper(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache) + public ProjectsHelper(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache, PermissionServices permission) { _context = context; _rolesHelper = rolesHelper; _cache = cache; + _permission = permission; } public async Task> GetAllProjectByTanentID(Guid tanentID) @@ -51,80 +52,32 @@ namespace MarcoBMS.Services.Helpers } } - public async Task> GetMyProjects(Guid tenantId, Employee LoggedInEmployee) + public async Task> GetMyProjects(Guid tenantId, Employee LoggedInEmployee) { - string[] projectsId = []; - List projects = new List(); - var projectIds = await _cache.GetProjects(LoggedInEmployee.Id); - if (projectIds != null) + if (projectIds == null) { - - List projectdetails = await _cache.GetProjectDetailsList(projectIds) ?? new List(); - projects = projectdetails.Select(p => new Project + var hasPermission = await _permission.HasPermission(LoggedInEmployee.Id, PermissionsMaster.ManageProject); + if (hasPermission) { - Id = Guid.Parse(p.Id), - Name = p.Name, - ShortName = p.ShortName, - ProjectAddress = p.ProjectAddress, - ProjectStatusId = Guid.Parse(p.ProjectStatus?.Id ?? ""), - ContactPerson = p.ContactPerson, - StartDate = p.StartDate, - EndDate = p.EndDate, - TenantId = tenantId - }).ToList(); - - if (projects.Count != projectIds.Count) - { - projects = await _context.Projects.Where(p => projectIds.Contains(p.Id)).ToListAsync(); - } - } - else - { - var featurePermissionIds = await _cache.GetPermissions(LoggedInEmployee.Id); - if (featurePermissionIds == null) - { - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(LoggedInEmployee.Id); - featurePermissionIds = featurePermission.Select(fp => fp.Id).ToList(); - } - // Define a common queryable base for projects - IQueryable projectQuery = _context.Projects.Where(c => c.TenantId == tenantId); - - // 2. Optimized Project Retrieval Logic - // User with permission 'manage project' can see all projects - if (featurePermissionIds != null && featurePermissionIds.Contains(Guid.Parse("172fc9b6-755b-4f62-ab26-55c34a330614"))) - { - // If GetAllProjectByTanentID is already optimized and directly returns IQueryable or - // directly executes with ToListAsync(), keep it. - // If it does more complex logic or extra trips, consider inlining here. - projects = await projectQuery.ToListAsync(); // Directly query the context + var projects = await _context.Projects.Where(c => c.TenantId == tenantId).ToListAsync(); + projectIds = projects.Select(p => p.Id).ToList(); } else { - // 3. Efficiently get project allocations and then filter projects - // Load allocations only once var allocation = await GetProjectByEmployeeID(LoggedInEmployee.Id); - - // If there are no allocations, return an empty list early - if (allocation == null || !allocation.Any()) + if (allocation.Any()) { - return new List(); + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); } - - // Use LINQ's Contains for efficient filtering by ProjectId - projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); // Get distinct Guids - - // Filter projects based on the retrieved ProjectIds - projects = await projectQuery.Where(c => projectIds.Contains(c.Id)).ToListAsync(); - + return new List(); } - projectIds = projects.Select(p => p.Id).ToList(); await _cache.AddProjects(LoggedInEmployee.Id, projectIds); } - return projects; + return projectIds; } } -} +} \ No newline at end of file diff --git a/Marco.Pms.Services/Helpers/ReportHelper.cs b/Marco.Pms.Services/Helpers/ReportHelper.cs index e7632fd..4ec0978 100644 --- a/Marco.Pms.Services/Helpers/ReportHelper.cs +++ b/Marco.Pms.Services/Helpers/ReportHelper.cs @@ -1,20 +1,28 @@ -using System.Globalization; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Attendance; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Mail; using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Report; +using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; +using System.Globalization; namespace Marco.Pms.Services.Helpers { public class ReportHelper { private readonly ApplicationDbContext _context; + private readonly IEmailSender _emailSender; + private readonly ILoggingService _logger; private readonly CacheUpdateHelper _cache; - public ReportHelper(CacheUpdateHelper cache, ApplicationDbContext context) + public ReportHelper(ApplicationDbContext context, IEmailSender emailSender, ILoggingService logger, CacheUpdateHelper cache) { - _cache = cache; _context = context; + _emailSender = emailSender; + _logger = logger; + _cache = cache; } public async Task GetDailyProjectReport(Guid projectId, Guid tenantId) { @@ -270,5 +278,88 @@ namespace Marco.Pms.Services.Helpers } return null; } + /// + /// Retrieves project statistics for a given project ID and sends an email report. + /// + /// The ID of the project. + /// The email address of the recipient. + /// An ApiResponse indicating the success or failure of retrieving statistics and sending the email. + public async Task> GetProjectStatistics(Guid projectId, List recipientEmails, string body, string subject, Guid tenantId) + { + // --- Input Validation --- + if (projectId == Guid.Empty) + { + _logger.LogError("Validation Error: Provided empty project ID while fetching project report."); + return ApiResponse.ErrorResponse("Provided empty Project ID.", "Provided empty Project ID.", 400); + } + + if (recipientEmails == null || !recipientEmails.Any()) + { + _logger.LogError("Validation Error: No recipient emails provided for project ID {ProjectId}.", projectId); + return ApiResponse.ErrorResponse("No recipient emails provided.", "No recipient emails provided.", 400); + } + + // --- Fetch Project Statistics --- + var statisticReport = await GetDailyProjectReport(projectId, tenantId); + + if (statisticReport == null) + { + _logger.LogWarning("Project Data Not Found: User attempted to fetch project progress for project ID {ProjectId} but it was not found.", projectId); + return ApiResponse.ErrorResponse("Project not found.", "Project not found.", 404); + } + + // --- Send Email & Log --- + string emailBody; + try + { + emailBody = await _emailSender.SendProjectStatisticsEmail(recipientEmails, body, subject, statisticReport); + } + catch (Exception ex) + { + _logger.LogError("Email Sending Error: Failed to send project statistics email for project ID {ProjectId}. : {Error}", projectId, ex.Message); + return ApiResponse.ErrorResponse("Failed to send email.", "An error occurred while sending the email.", 500); + } + + // Find a relevant employee. Use AsNoTracking() for read-only query if the entity won't be modified. + // Consider if you need *any* employee from the recipients or a specific one (e.g., the sender). + var employee = await _context.Employees + .AsNoTracking() // Optimize for read-only + .FirstOrDefaultAsync(e => e.Email != null && recipientEmails.Contains(e.Email)) ?? new Employee(); + + // Initialize Employee to a default or null, based on whether an employee is always expected. + // If employee.Id is a non-nullable type, ensure proper handling if employee is null. + Guid employeeId = employee.Id; // Default to Guid.Empty if no employee found + + var mailLogs = recipientEmails.Select(recipientEmail => new MailLog + { + ProjectId = projectId, + EmailId = recipientEmail, + Body = emailBody, + EmployeeId = employeeId, // Use the determined employeeId + TimeStamp = DateTime.UtcNow, + TenantId = tenantId + }).ToList(); + + _context.MailLogs.AddRange(mailLogs); + + try + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully sent and logged project statistics email for Project ID {ProjectId} to {RecipientCount} recipients.", projectId, recipientEmails.Count); + return ApiResponse.SuccessResponse(statisticReport, "Email sent successfully", 200); + } + catch (DbUpdateException dbEx) + { + _logger.LogError("Database Error: Failed to save mail logs for project ID {ProjectId}. : {Error}", projectId, dbEx.Message); + // Depending on your requirements, you might still return success here as the email was sent. + // Or return an error indicating the logging failed. + return ApiResponse.ErrorResponse("Email sent, but failed to log activity.", "Email sent, but an error occurred while logging.", 500); + } + catch (Exception ex) + { + _logger.LogError("Unexpected Error: An unhandled exception occurred while processing project statistics for project ID {ProjectId}. : {Error}", projectId, ex.Message); + return ApiResponse.ErrorResponse("An unexpected error occurred.", "An unexpected error occurred.", 500); + } + } } } diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs new file mode 100644 index 0000000..c7ec4af --- /dev/null +++ b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs @@ -0,0 +1,30 @@ +using AutoMapper; +using Marco.Pms.Model.Master; +using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.Projects; +using Marco.Pms.Model.ViewModels.Projects; + +namespace Marco.Pms.Services.MappingProfiles +{ + public class ProjectMappingProfile : Profile + { + public ProjectMappingProfile() + { + // Your mappings + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap() + .ForMember( + dest => dest.Id, + // Explicitly and safely convert string Id to Guid Id + opt => opt.MapFrom(src => src.Id == null ? Guid.Empty : new Guid(src.Id)) + ); + + CreateMap(); + CreateMap(); + } + } +} diff --git a/Marco.Pms.Services/Marco.Pms.Services.csproj b/Marco.Pms.Services/Marco.Pms.Services.csproj index a235e6a..2feafaf 100644 --- a/Marco.Pms.Services/Marco.Pms.Services.csproj +++ b/Marco.Pms.Services/Marco.Pms.Services.csproj @@ -11,6 +11,7 @@ + diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 30831c6..7fa2647 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -1,4 +1,3 @@ -using System.Text; using Marco.Pms.CacheHelper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Authentication; @@ -16,47 +15,23 @@ using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using Serilog; - +using System.Text; var builder = WebApplication.CreateBuilder(args); -// Add Serilog Configuration -string? mongoConn = builder.Configuration["MongoDB:SerilogDatabaseUrl"]; -string timeString = "00:00:30"; -TimeSpan.TryParse(timeString, out TimeSpan timeSpan); +#region ======================= Service Configuration (Dependency Injection) ======================= -// Add Serilog Configuration +#region Logging builder.Host.UseSerilog((context, config) => { - config.ReadFrom.Configuration(context.Configuration) // Taking all configuration from appsetting.json - .WriteTo.MongoDB( - databaseUrl: mongoConn ?? string.Empty, - collectionName: "api-logs", - batchPostingLimit: 100, - period: timeSpan - ); - + config.ReadFrom.Configuration(context.Configuration); }); +#endregion -// Add services -var corsSettings = builder.Configuration.GetSection("Cors"); -var allowedOrigins = corsSettings.GetValue("AllowedOrigins")?.Split(','); -var allowedMethods = corsSettings.GetValue("AllowedMethods")?.Split(','); -var allowedHeaders = corsSettings.GetValue("AllowedHeaders")?.Split(','); - +#region CORS (Cross-Origin Resource Sharing) builder.Services.AddCors(options => { - options.AddPolicy("Policy", policy => - { - if (allowedOrigins != null && allowedMethods != null && allowedHeaders != null) - { - policy.WithOrigins(allowedOrigins) - .WithMethods(allowedMethods) - .WithHeaders(allowedHeaders); - } - }); -}).AddCors(options => -{ + // A more permissive policy for development options.AddPolicy("DevCorsPolicy", policy => { policy.AllowAnyOrigin() @@ -64,93 +39,51 @@ builder.Services.AddCors(options => .AllowAnyHeader() .WithExposedHeaders("Authorization"); }); -}); -// Add services to the container. -builder.Services.AddHostedService(); + // A stricter policy for production (loaded from config) + var corsSettings = builder.Configuration.GetSection("Cors"); + var allowedOrigins = corsSettings.GetValue("AllowedOrigins")?.Split(',') ?? Array.Empty(); + options.AddPolicy("ProdCorsPolicy", policy => + { + policy.WithOrigins(allowedOrigins) + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); +#endregion + +#region Core Web & Framework Services builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddSignalR(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -builder.Services.AddSwaggerGen(option => -{ - option.SwaggerDoc("v1", new OpenApiInfo { Title = "Demo API", Version = "v1" }); - option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { - In = ParameterLocation.Header, - Description = "Please enter a valid token", - Name = "Authorization", - Type = SecuritySchemeType.Http, - BearerFormat = "JWT", - Scheme = "Bearer" - }); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddMemoryCache(); +builder.Services.AddAutoMapper(typeof(Program)); +builder.Services.AddHostedService(); +#endregion - option.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type=ReferenceType.SecurityScheme, - Id="Bearer" - } - }, - new string[]{} - } - }); -}); +#region Database & Identity +string? connString = builder.Configuration.GetConnectionString("DefaultConnectionString") + ?? throw new InvalidOperationException("Database connection string 'DefaultConnectionString' not found."); -builder.Services.Configure(builder.Configuration.GetSection("SmtpSettings")); -builder.Services.AddTransient(); - -builder.Services.Configure(builder.Configuration.GetSection("AWS")); // For uploading images to aws s3 -builder.Services.AddTransient(); - -builder.Services.AddIdentity().AddEntityFrameworkStores().AddDefaultTokenProviders(); - - -string? connString = builder.Configuration.GetConnectionString("DefaultConnectionString"); +// This single call correctly registers BOTH the DbContext (scoped) AND the IDbContextFactory (singleton). +builder.Services.AddDbContextFactory(options => + options.UseMySql(connString, ServerVersion.AutoDetect(connString))); builder.Services.AddDbContext(options => -{ - options.UseMySql(connString, ServerVersion.AutoDetect(connString)); -}); + options.UseMySql(connString, ServerVersion.AutoDetect(connString))); +builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); +#endregion -builder.Services.AddMemoryCache(); - - -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); - - -builder.Services.AddHttpContextAccessor(); - +#region Authentication (JWT) var jwtSettings = builder.Configuration.GetSection("Jwt").Get() ?? throw new InvalidOperationException("JwtSettings section is missing or invalid."); - if (jwtSettings != null && jwtSettings.Key != null) { + builder.Services.AddSingleton(jwtSettings); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -168,71 +101,129 @@ if (jwtSettings != null && jwtSettings.Key != null) ValidAudience = jwtSettings.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Key)) }; - + // This event allows SignalR to get the token from the query string options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; - var path = context.HttpContext.Request.Path; - - // Match your hub route here - if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs/marco")) + if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs/marco")) { context.Token = accessToken; } - return Task.CompletedTask; } }; }); - builder.Services.AddSingleton(jwtSettings); } +#endregion -builder.Services.AddSignalR(); +#region API Documentation (Swagger) +builder.Services.AddSwaggerGen(option => +{ + option.SwaggerDoc("v1", new OpenApiInfo { Title = "Marco PMS API", Version = "v1" }); + option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Please enter a valid token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + option.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } + }, + Array.Empty() + } + }); +}); +#endregion + +#region Application-Specific Services +// Configuration-bound services +builder.Services.Configure(builder.Configuration.GetSection("SmtpSettings")); +builder.Services.Configure(builder.Configuration.GetSection("AWS")); + +// Transient services (lightweight, created each time) +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +// Scoped services (one instance per HTTP request) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Singleton services (one instance for the app's lifetime) +builder.Services.AddSingleton(); +#endregion + +#region Web Server (Kestrel) builder.WebHost.ConfigureKestrel(options => { - options.AddServerHeader = false; // Disable the "Server" header + options.AddServerHeader = false; // Disable the "Server" header for security }); +#endregion + +#endregion var app = builder.Build(); +#region ===================== HTTP Request Pipeline Configuration ===================== + +// The order of middleware registration is critical for correct application behavior. + +#region Global Middleware (Run First) +// These custom middleware components run at the beginning of the pipeline to handle cross-cutting concerns. app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); +#endregion - - -// Configure the HTTP request pipeline. +#region Development Environment Configuration +// These tools are only enabled in the Development environment for debugging and API testing. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); - // Use CORS in the pipeline - app.UseCors("DevCorsPolicy"); } -else -{ - //if (app.Environment.IsProduction()) - //{ - // app.UseCors("ProdCorsPolicy"); - //} +#endregion - //app.UseCors("AllowAll"); - app.UseCors("DevCorsPolicy"); -} +#region Standard Middleware +// Common middleware for handling static content, security, and routing. +app.UseStaticFiles(); // Enables serving static files (e.g., from wwwroot) +app.UseHttpsRedirection(); // Redirects HTTP requests to HTTPS +#endregion -app.UseStaticFiles(); // Enables serving static files +#region Security (CORS, Authentication & Authorization) +// Security-related middleware must be in the correct order. +var corsPolicy = app.Environment.IsDevelopment() ? "DevCorsPolicy" : "ProdCorsPolicy"; +app.UseCors(corsPolicy); // CORS must be applied before Authentication/Authorization. -//app.UseSerilogRequestLogging(); // This is Default Serilog Logging Middleware we are not using this because we're using custom logging middleware +app.UseAuthentication(); // 1. Identifies who the user is. +app.UseAuthorization(); // 2. Determines what the identified user is allowed to do. +#endregion - -app.UseHttpsRedirection(); - - -app.UseAuthentication(); -app.UseAuthorization(); -app.MapHub("/hubs/marco"); +#region Endpoint Routing (Run Last) +// These map incoming requests to the correct controller actions or SignalR hubs. app.MapControllers(); +app.MapHub("/hubs/marco"); +#endregion -app.Run(); +#endregion + +app.Run(); \ No newline at end of file diff --git a/Marco.Pms.Services/Service/ILoggingService.cs b/Marco.Pms.Services/Service/ILoggingService.cs index 39dbb00..b835d0c 100644 --- a/Marco.Pms.Services/Service/ILoggingService.cs +++ b/Marco.Pms.Services/Service/ILoggingService.cs @@ -1,10 +1,9 @@ -using Serilog.Context; - -namespace MarcoBMS.Services.Service +namespace MarcoBMS.Services.Service { public interface ILoggingService { void LogInfo(string? message, params object[]? args); + void LogDebug(string? message, params object[]? args); void LogWarning(string? message, params object[]? args); void LogError(string? message, params object[]? args); diff --git a/Marco.Pms.Services/Service/LoggingServices.cs b/Marco.Pms.Services/Service/LoggingServices.cs index 4328a2a..5a016de 100644 --- a/Marco.Pms.Services/Service/LoggingServices.cs +++ b/Marco.Pms.Services/Service/LoggingServices.cs @@ -18,10 +18,11 @@ namespace MarcoBMS.Services.Service { _logger.LogError(message, args); } - else { + else + { _logger.LogError(message); } - } + } public void LogInfo(string? message, params object[]? args) { @@ -35,6 +36,18 @@ namespace MarcoBMS.Services.Service _logger.LogInformation(message); } } + public void LogDebug(string? message, params object[]? args) + { + using (LogContext.PushProperty("LogLevel", "Information")) + if (args != null) + { + _logger.LogDebug(message, args); + } + else + { + _logger.LogDebug(message); + } + } public void LogWarning(string? message, params object[]? args) { @@ -49,6 +62,5 @@ namespace MarcoBMS.Services.Service } } } - } diff --git a/Marco.Pms.Services/Service/PermissionServices.cs b/Marco.Pms.Services/Service/PermissionServices.cs index ce7476b..7162dc5 100644 --- a/Marco.Pms.Services/Service/PermissionServices.cs +++ b/Marco.Pms.Services/Service/PermissionServices.cs @@ -1,7 +1,6 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; -using Marco.Pms.Model.Projects; using Marco.Pms.Services.Helpers; using MarcoBMS.Services.Helpers; using Microsoft.EntityFrameworkCore; @@ -12,13 +11,11 @@ namespace Marco.Pms.Services.Service { private readonly ApplicationDbContext _context; private readonly RolesHelper _rolesHelper; - private readonly ProjectsHelper _projectsHelper; private readonly CacheUpdateHelper _cache; - public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, ProjectsHelper projectsHelper, CacheUpdateHelper cache) + public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache) { _context = context; _rolesHelper = rolesHelper; - _projectsHelper = projectsHelper; _cache = cache; } @@ -33,24 +30,31 @@ namespace Marco.Pms.Services.Service var hasPermission = featurePermissionIds.Contains(featurePermissionId); return hasPermission; } - public async Task HasProjectPermission(Employee emp, string projectId) + public async Task HasProjectPermission(Employee LoggedInEmployee, Guid projectId) { - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(emp.Id); - string[] projectsId = []; + var employeeId = LoggedInEmployee.Id; + var projectIds = await _cache.GetProjects(employeeId); - /* User with permission manage project can see all projects */ - if (featurePermission != null && featurePermission.Exists(c => c.Id.ToString() == "172fc9b6-755b-4f62-ab26-55c34a330614")) + if (projectIds == null) { - List projects = await _projectsHelper.GetAllProjectByTanentID(emp.TenantId); - projectsId = projects.Select(c => c.Id.ToString()).ToArray(); + var hasPermission = await HasPermission(employeeId, PermissionsMaster.ManageProject); + if (hasPermission) + { + var projects = await _context.Projects.Where(c => c.TenantId == LoggedInEmployee.TenantId).ToListAsync(); + projectIds = projects.Select(p => p.Id).ToList(); + } + else + { + var allocation = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive == true).ToListAsync(); + if (allocation.Any()) + { + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); + } + return false; + } + await _cache.AddProjects(LoggedInEmployee.Id, projectIds); } - else - { - List allocation = await _projectsHelper.GetProjectByEmployeeID(emp.Id); - projectsId = allocation.Select(c => c.ProjectId.ToString()).ToArray(); - } - bool response = projectsId.Contains(projectId); - return response; + return projectIds.Contains(projectId); } } } -- 2.43.0 From 4ba533f64791cd2800f063f391899b048b693759 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 14 Jul 2025 12:02:45 +0530 Subject: [PATCH 03/50] Optimized both get Project list API and get Project list basic API --- Marco.Pms.CacheHelper/ProjectCache.cs | 43 +- .../Controllers/ProjectController.cs | 727 +++++++++--------- .../Controllers/UserController.cs | 2 +- .../Helpers/CacheUpdateHelper.cs | 43 +- Marco.Pms.Services/Helpers/ProjectsHelper.cs | 2 +- Marco.Pms.Services/Helpers/RolesHelper.cs | 121 ++- .../Service/PermissionServices.cs | 2 +- 7 files changed, 513 insertions(+), 427 deletions(-) diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index 1fd36f4..183bbc4 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -32,20 +32,9 @@ namespace Marco.Pms.CacheHelper public async Task AddProjectDetailsListToCache(List projectDetailsList) { await _projetCollection.InsertManyAsync(projectDetailsList); - //_logger.LogInfo("[AddProjectDetails] Project details inserted in MongoDB for ProjectId: {ProjectId}", project.Id); } - public async Task UpdateProjectDetailsOnlyToCache(Project project) + public async Task UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus) { - //_logger.LogInfo("Starting update for project: {ProjectId}", project.Id); - - var projectStatus = await _context.StatusMasters - .FirstOrDefaultAsync(s => s.Id == project.ProjectStatusId); - - if (projectStatus == null) - { - //_logger.LogWarning("StatusMaster not found for ProjectStatusId: {StatusId}", project.ProjectStatusId); - } - // Build the update definition var updates = Builders.Update.Combine( Builders.Update.Set(r => r.Name, project.Name), @@ -69,11 +58,9 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("No project matched in MongoDB for update. ProjectId: {ProjectId}", project.Id); return false; } - //_logger.LogInfo("Project {ProjectId} successfully updated in MongoDB", project.Id); return true; } public async Task GetProjectDetailsFromCache(Guid projectId) @@ -83,21 +70,12 @@ namespace Marco.Pms.CacheHelper var filter = Builders.Filter.Eq(p => p.Id, projectId.ToString()); var projection = Builders.Projection.Exclude(p => p.Buildings); - //_logger.LogInfo("Fetching project details for ProjectId: {ProjectId} from MongoDB", projectId); - // Perform query var project = await _projetCollection .Find(filter) .Project(projection) .FirstOrDefaultAsync(); - if (project == null) - { - //_logger.LogWarning("No project found in MongoDB for ProjectId: {ProjectId}", projectId); - return null; - } - - //_logger.LogInfo("Successfully fetched project details (excluding Buildings) for ProjectId: {ProjectId}", projectId); return project; } public async Task> GetProjectDetailsListFromCache(List projectIds) @@ -111,6 +89,12 @@ namespace Marco.Pms.CacheHelper .ToListAsync(); return projects; } + public async Task DeleteProjectByIdFromCacheAsync(Guid projectId) + { + var filter = Builders.Filter.Eq(e => e.Id, projectId.ToString()); + var result = await _projetCollection.DeleteOneAsync(filter); + return result.DeletedCount > 0; + } // ------------------------------------------------------- Project InfraStructure ------------------------------------------------------- @@ -407,6 +391,10 @@ namespace Marco.Pms.CacheHelper return null; return result; } + + + // ------------------------------------------------------- WorkItem ------------------------------------------------------- + public async Task> GetWorkItemsByWorkAreaIdsFromCache(List workAreaIds) { var stringWorkAreaIds = workAreaIds.Select(wa => wa.ToString()).ToList(); @@ -418,9 +406,6 @@ namespace Marco.Pms.CacheHelper return workItems; } - - // ------------------------------------------------------- WorkItem ------------------------------------------------------- - public async Task ManageWorkItemDetailsToCache(List workItems) { var activityIds = workItems.Select(wi => wi.ActivityId).ToList(); @@ -510,5 +495,11 @@ namespace Marco.Pms.CacheHelper } return false; } + public async Task DeleteWorkItemByIdFromCacheAsync(Guid workItemId) + { + var filter = Builders.Filter.Eq(e => e.Id, workItemId.ToString()); + var result = await _taskCollection.DeleteOneAsync(filter); + return result.DeletedCount > 0; + } } } diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 29f9d04..adb5887 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -28,10 +28,10 @@ namespace MarcoBMS.Services.Controllers [Authorize] public class ProjectController : ControllerBase { + private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly ILoggingService _logger; - //private readonly RolesHelper _rolesHelper; private readonly ProjectsHelper _projectsHelper; private readonly IHubContext _signalR; private readonly PermissionServices _permission; @@ -40,13 +40,13 @@ namespace MarcoBMS.Services.Controllers private readonly Guid tenantId; - public ProjectController(ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, RolesHelper rolesHelper, ProjectsHelper projectHelper, - IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IMapper mapper) + public ProjectController(IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, RolesHelper rolesHelper, + ProjectsHelper projectHelper, IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IMapper mapper) { + _dbContextFactory = dbContextFactory; _context = context; _userHelper = userHelper; _logger = logger; - //_rolesHelper = rolesHelper; _projectsHelper = projectHelper; _signalR = signalR; _cache = cache; @@ -55,55 +55,10 @@ namespace MarcoBMS.Services.Controllers tenantId = _userHelper.GetTenantId(); } - [HttpGet("list/basic1")] - public async Task GetAllProjects1() - { - if (!ModelState.IsValid) - { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - - } - Guid tenantId = _userHelper.GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // Defensive check for null employee (important for robust APIs) - if (LoggedInEmployee == null) - { - return Unauthorized(ApiResponse.ErrorResponse("Employee not found.", null, 401)); - } - - List response = new List(); - List projectIds = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - - List? projectsDetails = await _cache.GetProjectDetailsList(projectIds); - if (projectsDetails == null) - { - List projects = await _context.Projects.Where(p => projectIds.Contains(p.Id)).ToListAsync(); - //using (var scope = _serviceScopeFactory.CreateScope()) - //{ - // var cacheHelper = scope.ServiceProvider.GetRequiredService(); - - //} - foreach (var project in projects) - { - await _cache.AddProjectDetails(project); - } - response = projects.Select(p => _mapper.Map(p)).ToList(); - } - else - { - response = projectsDetails.Select(p => _mapper.Map(p)).ToList(); - } - - return Ok(ApiResponse.SuccessResponse(response, "Success.", 200)); - } + #region =================================================================== Project Get APIs =================================================================== [HttpGet("list/basic")] - public async Task GetAllProjects() // Renamed for clarity + public async Task GetAllProjectsBasic() { // Step 1: Get the current user var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); @@ -133,146 +88,82 @@ namespace MarcoBMS.Services.Controllers } /// - /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. - /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the - /// database (as Project), updates the cache, and returns a unified list of ViewModels. + /// Retrieves a list of projects accessible to the current user, including aggregated details. + /// This method is optimized to use a cache-first approach. If data is not in the cache, + /// it fetches and aggregates data efficiently from the database in parallel. /// - /// The list of project IDs to retrieve. - /// A list of ProjectInfoVMs. - private async Task> GetProjectInfosByIdsAsync(List projectIds) - { - // --- Step 1: Fetch from Cache --- - // The cache returns a list of MongoDB documents for the projects it found. - var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); - var finalViewModels = _mapper.Map>(cachedMongoDocs); - - _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); - - // --- Step 2: Identify Missing Projects --- - // If we found everything in the cache, we can return early. - if (finalViewModels.Count == projectIds.Count) - { - return finalViewModels; - } - - var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id - var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); - - // --- Step 3: Fetch Missing from Database --- - if (missingIds.Any()) - { - _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); - - var projectsFromDb = await _context.Projects - .Where(p => missingIds.Contains(p.Id)) - .AsNoTracking() // Use AsNoTracking for read-only query performance - .ToListAsync(); - - if (projectsFromDb.Any()) - { - // Map the newly fetched projects (from SQL) to their ViewModel - var vmsFromDb = _mapper.Map>(projectsFromDb); - finalViewModels.AddRange(vmsFromDb); - - // --- Step 4: Update Cache with Missing Items in a new scope --- - _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); - await _cache.AddProjectDetailsList(projectsFromDb); - } - } - - return finalViewModels; - } + /// An ApiResponse containing a list of projects or an error. [HttpGet("list")] - public async Task GetAll() + public async Task GetAllProjects() { + // --- Step 1: Input Validation and Initial Setup --- if (!ModelState.IsValid) { var errors = ModelState.Values .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + _logger.LogWarning("GetAllProjects called with invalid model state. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - Guid tenantId = _userHelper.GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - //List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(LoggedInEmployee.Id); - //string[] projectsId = []; - //List projects = new List(); - ///* User with permission manage project can see all projects */ - //if (featurePermission != null && featurePermission.Exists(c => c.Id.ToString() == "172fc9b6-755b-4f62-ab26-55c34a330614")) - //{ - // projects = await _projectsHelper.GetAllProjectByTanentID(LoggedInEmployee.TenantId); - //} - //else - //{ - // List allocation = await _projectsHelper.GetProjectByEmployeeID(LoggedInEmployee.Id); - // projectsId = allocation.Select(c => c.ProjectId.ToString()).ToArray(); - // projects = await _context.Projects.Where(c => projectsId.Contains(c.Id.ToString()) && c.TenantId == tenantId).ToListAsync(); - //} - - //List projects = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - ////List projects = new List(); - /// - List response = new List(); - List projectIds = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - - var projectsDetails = await _cache.GetProjectDetailsList(projectIds); - if (projectsDetails == null) + try { - List projects = await _context.Projects.Where(p => projectIds.Contains(p.Id)).ToListAsync(); + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + _logger.LogInfo("Starting GetAllProjects for TenantId: {TenantId}, User: {UserId}", tenantId, loggedInEmployee.Id); - var teams = await _context.ProjectAllocations.Where(p => p.TenantId == tenantId && projectIds.Contains(p.ProjectId) && p.IsActive == true).ToListAsync(); - - - List allBuildings = await _context.Buildings.Where(b => projectIds.Contains(b.ProjectId) && b.TenantId == tenantId).ToListAsync(); - List idList = allBuildings.Select(b => b.Id).ToList(); - - List allFloors = await _context.Floor.Where(f => idList.Contains(f.BuildingId) && f.TenantId == tenantId).ToListAsync(); - idList = allFloors.Select(f => f.Id).ToList(); - - List allWorkAreas = await _context.WorkAreas.Where(a => idList.Contains(a.FloorId) && a.TenantId == tenantId).ToListAsync(); - idList = allWorkAreas.Select(a => a.Id).ToList(); - - List allWorkItems = await _context.WorkItems.Where(i => idList.Contains(i.WorkAreaId) && i.TenantId == tenantId).Include(i => i.ActivityMaster).ToListAsync(); - - foreach (var project in projects) + // --- Step 2: Get a list of project IDs the user can access --- + List projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + if (!projectIds.Any()) { - var result = _mapper.Map(project); - var team = teams.Where(p => p.TenantId == tenantId && p.ProjectId == project.Id && p.IsActive == true).ToList(); - - result.TeamSize = team.Count(); - - List buildings = allBuildings.Where(b => b.ProjectId == project.Id && b.TenantId == tenantId).ToList(); - idList = buildings.Select(b => b.Id).ToList(); - - List floors = allFloors.Where(f => idList.Contains(f.BuildingId) && f.TenantId == tenantId).ToList(); - idList = floors.Select(f => f.Id).ToList(); - - List workAreas = allWorkAreas.Where(a => idList.Contains(a.FloorId) && a.TenantId == tenantId).ToList(); - idList = workAreas.Select(a => a.Id).ToList(); - - List workItems = allWorkItems.Where(i => idList.Contains(i.WorkAreaId) && i.TenantId == tenantId).ToList(); - double completedTask = 0; - double plannedTask = 0; - foreach (var workItem in workItems) - { - completedTask += workItem.CompletedWork; - plannedTask += workItem.PlannedWork; - } - result.PlannedWork = plannedTask; - result.CompletedWork = completedTask; - response.Add(result); + _logger.LogInfo("User has no assigned projects. Returning empty list."); + return Ok(ApiResponse>.SuccessResponse(new List(), "No projects found for the current user.", 200)); } - } - else - { - response = projectsDetails.Select(p => _mapper.Map(p)).ToList(); - } - return Ok(ApiResponse.SuccessResponse(response, "Success.", 200)); + // --- Step 3: Efficiently handle partial cache hits --- + _logger.LogInfo("Attempting to fetch details for {ProjectCount} projects from cache.", projectIds.Count); + + // Fetch what we can from the cache. + var cachedDetails = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var cachedDictionary = cachedDetails.ToDictionary(p => Guid.Parse(p.Id)); + + // Identify which projects are missing from the cache. + var missingIds = projectIds.Where(id => !cachedDictionary.ContainsKey(id)).ToList(); + + // Start building the response with the items we found in the cache. + var responseVms = _mapper.Map>(cachedDictionary.Values); + + if (missingIds.Any()) + { + // --- Step 4: Fetch ONLY the missing items from the database --- + _logger.LogInfo("Cache partial MISS. Found {CachedCount}, fetching {MissingCount} projects from DB.", + cachedDictionary.Count, missingIds.Count); + + // Call our dedicated data-fetching method for the missing IDs. + var newMongoDetails = await FetchAndBuildProjectDetails(missingIds, tenantId); + + if (newMongoDetails.Any()) + { + // Map the newly fetched items and add them to our response list. + responseVms.AddRange(newMongoDetails); + } + } + else + { + _logger.LogInfo("Cache HIT. All {ProjectCount} projects found in cache.", projectIds.Count); + } + + // --- Step 5: Return the combined result --- + _logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", responseVms.Count); + return Ok(ApiResponse>.SuccessResponse(responseVms, "Projects retrieved successfully.", 200)); + } + catch (Exception ex) + { + // --- Step 6: Graceful Error Handling --- + _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. : {Error}", tenantId, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500)); + } } [HttpGet("get/{id}")] @@ -351,23 +242,6 @@ namespace MarcoBMS.Services.Controllers { projectVM.ProjectStatus.TenantId = tenantId; } - //projectVM = new ProjectVM - //{ - // Id = projectDetails.Id != null ? Guid.Parse(projectDetails.Id) : Guid.Empty, - // Name = projectDetails.Name, - // ShortName = projectDetails.ShortName, - // ProjectAddress = projectDetails.ProjectAddress, - // StartDate = projectDetails.StartDate, - // EndDate = projectDetails.EndDate, - // ContactPerson = projectDetails.ContactPerson, - // ProjectStatus = new StatusMaster - // { - // Id = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, - // Status = projectDetails.ProjectStatus?.Status, - // TenantId = tenantId - // } - // //ProjectStatusId = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, - //}; } if (projectVM == null) @@ -486,40 +360,9 @@ namespace MarcoBMS.Services.Controllers } - private async Task GetProjectViewModel(Guid? id, Project project) - { - ProjectDetailsVM vm = new ProjectDetailsVM(); + #endregion - // List buildings = _unitOfWork.Building.GetAll(c => c.ProjectId == id).ToList(); - List buildings = await _context.Buildings.Where(c => c.ProjectId == id).ToListAsync(); - List idList = buildings.Select(o => o.Id).ToList(); - // List floors = _unitOfWork.Floor.GetAll(c => idList.Contains(c.Id)).ToList(); - List floors = await _context.Floor.Where(c => idList.Contains(c.BuildingId)).ToListAsync(); - idList = floors.Select(o => o.Id).ToList(); - //List workAreas = _unitOfWork.WorkArea.GetAll(c => idList.Contains(c.Id), includeProperties: "WorkItems,WorkItems.ActivityMaster").ToList(); - - List workAreas = await _context.WorkAreas.Where(c => idList.Contains(c.FloorId)).ToListAsync(); - - idList = workAreas.Select(o => o.Id).ToList(); - List workItems = await _context.WorkItems.Include(c => c.WorkCategoryMaster).Where(c => idList.Contains(c.WorkAreaId)).Include(c => c.ActivityMaster).ToListAsync(); - // List workItems = _unitOfWork.WorkItem.GetAll(c => idList.Contains(c.WorkAreaId), includeProperties: "ActivityMaster").ToList(); - idList = workItems.Select(t => t.Id).ToList(); - List tasks = await _context.TaskAllocations.Where(t => idList.Contains(t.WorkItemId) && t.AssignmentDate.Date == DateTime.UtcNow.Date).ToListAsync(); - vm.project = project; - vm.buildings = buildings; - vm.floors = floors; - vm.workAreas = workAreas; - vm.workItems = workItems; - vm.Tasks = tasks; - return vm; - } - - private Guid GetTenantId() - { - return _userHelper.GetTenantId(); - //var tenant = User.FindFirst("TenantId")?.Value; - //return (tenant != null ? Convert.ToInt32(tenant) : 1); - } + #region =================================================================== Project Manage APIs =================================================================== [HttpPost] public async Task Create([FromBody] CreateProjectDto projectDto) @@ -619,50 +462,9 @@ namespace MarcoBMS.Services.Controllers } } - //[HttpPost("assign-employee")] - //public async Task AssignEmployee(int? allocationid, int employeeId, int projectId) - //{ - // var employee = await _context.Employees.FindAsync(employeeId); - // var project = _projectrepo.Get(c => c.Id == projectId); - // if (employee == null || project == null) - // { - // return NotFound(); - // } + #endregion - // // Logic to add the product to a new table (e.g., selected products) - - // if (allocationid == null) - // { - // // Add allocation - // ProjectAllocation allocation = new ProjectAllocation() - // { - // EmployeeId = employeeId, - // ProjectId = project.Id, - // AllocationDate = DateTime.UtcNow, - // //EmployeeRole = employee.Rol - // TenantId = project.TenantId - // }; - - // _unitOfWork.ProjectAllocation.CreateAsync(allocation); - // } - // else - // { - // //remove allocation - // var allocation = await _context.ProjectAllocations.FindAsync(allocationid); - // if (allocation != null) - // { - // allocation.ReAllocationDate = DateTime.UtcNow; - - // _unitOfWork.ProjectAllocation.UpdateAsync(allocation.Id, allocation); - // } - // else - // { - // return NotFound(); - // } - // } - - // return Ok(); - //} + #region =================================================================== Project Allocation APIs =================================================================== [HttpGet] [Route("employees/get/{projectid?}/{includeInactive?}")] @@ -838,6 +640,134 @@ namespace MarcoBMS.Services.Controllers } + [HttpGet("assigned-projects/{employeeId}")] + public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) + { + + Guid tenantId = _userHelper.GetTenantId(); + if (employeeId == Guid.Empty) + { + return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Employee id not valid.", 400)); + } + + List projectList = await _context.ProjectAllocations + .Where(c => c.TenantId == tenantId && c.EmployeeId == employeeId && c.IsActive) + .Select(c => c.ProjectId).Distinct() + .ToListAsync(); + + if (!projectList.Any()) + { + return NotFound(ApiResponse.SuccessResponse(new List(), "No projects found.", 200)); + } + + + List projectlist = await _context.Projects + .Where(p => projectList.Contains(p.Id)) + .ToListAsync(); + + List projects = new List(); + + + foreach (var project in projectlist) + { + + projects.Add(project.ToProjectListVMFromProject()); + } + + + + return Ok(ApiResponse.SuccessResponse(projects, "Success.", 200)); + } + + [HttpPost("assign-projects/{employeeId}")] + public async Task AssigneProjectsToEmployee([FromBody] List projectAllocationDtos, [FromRoute] Guid employeeId) + { + if (projectAllocationDtos != null && employeeId != Guid.Empty) + { + Guid TenentID = GetTenantId(); + var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + List? result = new List(); + List projectIds = new List(); + + foreach (var projectAllocationDto in projectAllocationDtos) + { + try + { + ProjectAllocation projectAllocation = projectAllocationDto.ToProjectAllocationFromProjectsAllocationDto(TenentID, employeeId); + ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.ProjectId == projectAllocationDto.ProjectId && c.ReAllocationDate == null && c.TenantId == TenentID).SingleOrDefaultAsync(); + + if (projectAllocationFromDb != null) + { + + + _context.ProjectAllocations.Attach(projectAllocationFromDb); + + if (projectAllocationDto.Status) + { + projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; + projectAllocationFromDb.IsActive = true; + _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; + _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; + } + else + { + projectAllocationFromDb.ReAllocationDate = DateTime.UtcNow; + projectAllocationFromDb.IsActive = false; + _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; + _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; + + projectIds.Add(projectAllocation.ProjectId); + } + await _context.SaveChangesAsync(); + var result1 = new + { + Id = projectAllocationFromDb.Id, + EmployeeId = projectAllocation.EmployeeId, + JobRoleId = projectAllocation.JobRoleId, + IsActive = projectAllocation.IsActive, + ProjectId = projectAllocation.ProjectId, + AllocationDate = projectAllocation.AllocationDate, + ReAllocationDate = projectAllocation.ReAllocationDate, + TenantId = projectAllocation.TenantId + }; + result.Add(result1); + } + else + { + projectAllocation.AllocationDate = DateTime.Now; + projectAllocation.IsActive = true; + _context.ProjectAllocations.Add(projectAllocation); + await _context.SaveChangesAsync(); + + projectIds.Add(projectAllocation.ProjectId); + + } + + + } + catch (Exception ex) + { + + return Ok(ApiResponse.ErrorResponse(ex.Message, ex, 400)); + } + } + await _cache.ClearAllProjectIds(employeeId); + var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; + + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + + return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); + } + else + { + return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "All Field is required", 400)); + } + + } + + #endregion + + #region =================================================================== Project InfraStructure Get APIs =================================================================== [HttpGet("infra-details/{projectId}")] public async Task GetInfraDetails(Guid projectId) @@ -1026,6 +956,10 @@ namespace MarcoBMS.Services.Controllers return Ok(ApiResponse.SuccessResponse(workItemVMs, $"{workItemVMs.Count} records of tasks fetched successfully", 200)); } + #endregion + + #region =================================================================== Project Infrastructre Manage APIs =================================================================== + [HttpPost("task")] public async Task CreateProjectTask(List workItemDtos) { @@ -1309,131 +1243,172 @@ namespace MarcoBMS.Services.Controllers } - [HttpGet("assigned-projects/{employeeId}")] - public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) + #endregion + + #region =================================================================== Helper Functions =================================================================== + + /// + /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. + /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the + /// database (as Project), updates the cache, and returns a unified list of ViewModels. + /// + /// The list of project IDs to retrieve. + /// A list of ProjectInfoVMs. + private async Task> GetProjectInfosByIdsAsync(List projectIds) { + // --- Step 1: Fetch from Cache --- + // The cache returns a list of MongoDB documents for the projects it found. + var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var finalViewModels = _mapper.Map>(cachedMongoDocs); - Guid tenantId = _userHelper.GetTenantId(); - if (employeeId == Guid.Empty) + _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); + + // --- Step 2: Identify Missing Projects --- + // If we found everything in the cache, we can return early. + if (finalViewModels.Count == projectIds.Count) { - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Employee id not valid.", 400)); + return finalViewModels; } - List projectList = await _context.ProjectAllocations - .Where(c => c.TenantId == tenantId && c.EmployeeId == employeeId && c.IsActive) - .Select(c => c.ProjectId).Distinct() - .ToListAsync(); + var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id + var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); - if (!projectList.Any()) + // --- Step 3: Fetch Missing from Database --- + if (missingIds.Any()) { - return NotFound(ApiResponse.SuccessResponse(new List(), "No projects found.", 200)); - } + _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); + var projectsFromDb = await _context.Projects + .Where(p => missingIds.Contains(p.Id)) + .AsNoTracking() // Use AsNoTracking for read-only query performance + .ToListAsync(); - List projectlist = await _context.Projects - .Where(p => projectList.Contains(p.Id)) - .ToListAsync(); - - List projects = new List(); - - - foreach (var project in projectlist) - { - - projects.Add(project.ToProjectListVMFromProject()); - } - - - - return Ok(ApiResponse.SuccessResponse(projects, "Success.", 200)); - } - - [HttpPost("assign-projects/{employeeId}")] - public async Task AssigneProjectsToEmployee([FromBody] List projectAllocationDtos, [FromRoute] Guid employeeId) - { - if (projectAllocationDtos != null && employeeId != Guid.Empty) - { - Guid TenentID = GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - List? result = new List(); - List projectIds = new List(); - - foreach (var projectAllocationDto in projectAllocationDtos) + if (projectsFromDb.Any()) { - try - { - ProjectAllocation projectAllocation = projectAllocationDto.ToProjectAllocationFromProjectsAllocationDto(TenentID, employeeId); - ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.ProjectId == projectAllocationDto.ProjectId && c.ReAllocationDate == null && c.TenantId == TenentID).SingleOrDefaultAsync(); + // Map the newly fetched projects (from SQL) to their ViewModel + var vmsFromDb = _mapper.Map>(projectsFromDb); + finalViewModels.AddRange(vmsFromDb); - if (projectAllocationFromDb != null) - { - - - _context.ProjectAllocations.Attach(projectAllocationFromDb); - - if (projectAllocationDto.Status) - { - projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; - projectAllocationFromDb.IsActive = true; - _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - } - else - { - projectAllocationFromDb.ReAllocationDate = DateTime.UtcNow; - projectAllocationFromDb.IsActive = false; - _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - - projectIds.Add(projectAllocation.ProjectId); - } - await _context.SaveChangesAsync(); - var result1 = new - { - Id = projectAllocationFromDb.Id, - EmployeeId = projectAllocation.EmployeeId, - JobRoleId = projectAllocation.JobRoleId, - IsActive = projectAllocation.IsActive, - ProjectId = projectAllocation.ProjectId, - AllocationDate = projectAllocation.AllocationDate, - ReAllocationDate = projectAllocation.ReAllocationDate, - TenantId = projectAllocation.TenantId - }; - result.Add(result1); - } - else - { - projectAllocation.AllocationDate = DateTime.Now; - projectAllocation.IsActive = true; - _context.ProjectAllocations.Add(projectAllocation); - await _context.SaveChangesAsync(); - - projectIds.Add(projectAllocation.ProjectId); - - } - - - } - catch (Exception ex) - { - - return Ok(ApiResponse.ErrorResponse(ex.Message, ex, 400)); - } + // --- Step 4: Update Cache with Missing Items in a new scope --- + _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); + await _cache.AddProjectDetailsList(projectsFromDb); } - await _cache.ClearAllProjectIds(employeeId); - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; - - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - - return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); - } - else - { - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "All Field is required", 400)); } + return finalViewModels; + } + + private Guid GetTenantId() + { + return _userHelper.GetTenantId(); + } + + private async Task GetProjectViewModel(Guid? id, Project project) + { + ProjectDetailsVM vm = new ProjectDetailsVM(); + + // List buildings = _unitOfWork.Building.GetAll(c => c.ProjectId == id).ToList(); + List buildings = await _context.Buildings.Where(c => c.ProjectId == id).ToListAsync(); + List idList = buildings.Select(o => o.Id).ToList(); + // List floors = _unitOfWork.Floor.GetAll(c => idList.Contains(c.Id)).ToList(); + List floors = await _context.Floor.Where(c => idList.Contains(c.BuildingId)).ToListAsync(); + idList = floors.Select(o => o.Id).ToList(); + //List workAreas = _unitOfWork.WorkArea.GetAll(c => idList.Contains(c.Id), includeProperties: "WorkItems,WorkItems.ActivityMaster").ToList(); + + List workAreas = await _context.WorkAreas.Where(c => idList.Contains(c.FloorId)).ToListAsync(); + + idList = workAreas.Select(o => o.Id).ToList(); + List workItems = await _context.WorkItems.Include(c => c.WorkCategoryMaster).Where(c => idList.Contains(c.WorkAreaId)).Include(c => c.ActivityMaster).ToListAsync(); + // List workItems = _unitOfWork.WorkItem.GetAll(c => idList.Contains(c.WorkAreaId), includeProperties: "ActivityMaster").ToList(); + idList = workItems.Select(t => t.Id).ToList(); + List tasks = await _context.TaskAllocations.Where(t => idList.Contains(t.WorkItemId) && t.AssignmentDate.Date == DateTime.UtcNow.Date).ToListAsync(); + vm.project = project; + vm.buildings = buildings; + vm.floors = floors; + vm.workAreas = workAreas; + vm.workItems = workItems; + vm.Tasks = tasks; + return vm; } + /// + /// Fetches project details from the database for a given list of project IDs and assembles them into MongoDB models. + /// This method encapsulates the optimized, parallel database queries. + /// + /// The list of project IDs to fetch. + /// The current tenant ID for filtering. + /// A list of fully populated ProjectMongoDB objects. + private async Task> FetchAndBuildProjectDetails(List projectIdsToFetch, Guid tenantId) + { + // Task to get base project details for the MISSING projects + var projectsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Projects.AsNoTracking() + .Where(p => projectIdsToFetch.Contains(p.Id) && p.TenantId == tenantId) + .ToListAsync(); + }); + + // Task to get team sizes for the MISSING projects + var teamSizesTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.ProjectAllocations.AsNoTracking() + .Where(pa => pa.TenantId == tenantId && projectIdsToFetch.Contains(pa.ProjectId) && pa.IsActive) + .GroupBy(pa => pa.ProjectId) + .Select(g => new { ProjectId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ProjectId, x => x.Count); + }); + + // Task to get work summaries for the MISSING projects + var workSummariesTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.WorkItems.AsNoTracking() + .Where(wi => wi.TenantId == tenantId && + wi.WorkArea != null && + wi.WorkArea.Floor != null && + wi.WorkArea.Floor.Building != null && + projectIdsToFetch.Contains(wi.WorkArea.Floor.Building.ProjectId)) + .GroupBy(wi => wi.WorkArea!.Floor!.Building!.ProjectId) + .Select(g => new { ProjectId = g.Key, PlannedWork = g.Sum(i => i.PlannedWork), CompletedWork = g.Sum(i => i.CompletedWork) }) + .ToDictionaryAsync(x => x.ProjectId); + }); + + // Await all parallel tasks to complete + await Task.WhenAll(projectsTask, teamSizesTask, workSummariesTask); + + var projects = await projectsTask; + var teamSizes = await teamSizesTask; + var workSummaries = await workSummariesTask; + + // Proactively update the cache with the items we just fetched. + _logger.LogInfo("Updating cache with {NewItemCount} newly fetched projects.", projects.Count); + await _cache.AddProjectDetailsList(projects); + + // This section would build the full ProjectMongoDB objects, similar to your AddProjectDetailsList method. + // For brevity, assuming you have a mapper or a builder for this. Here's a simplified representation: + var mongoDetailsList = new List(); + foreach (var project in projects) + { + // This is a placeholder for the full build logic from your other methods. + // In a real scenario, you would fetch all hierarchy levels (buildings, floors, etc.) + // for the `projectIdsToFetch` and build the complete MongoDB object. + var mongoDetail = _mapper.Map(project); + mongoDetail.Id = project.Id; + mongoDetail.TeamSize = teamSizes.GetValueOrDefault(project.Id, 0); + if (workSummaries.TryGetValue(project.Id, out var summary)) + { + mongoDetail.PlannedWork = summary.PlannedWork; + mongoDetail.CompletedWork = summary.CompletedWork; + } + mongoDetailsList.Add(mongoDetail); + } + + return mongoDetailsList; + } + + #endregion } } \ No newline at end of file diff --git a/Marco.Pms.Services/Controllers/UserController.cs b/Marco.Pms.Services/Controllers/UserController.cs index 2aeb208..4bb4432 100644 --- a/Marco.Pms.Services/Controllers/UserController.cs +++ b/Marco.Pms.Services/Controllers/UserController.cs @@ -50,7 +50,7 @@ namespace MarcoBMS.Services.Controllers emp = await _employeeHelper.GetEmployeeByApplicationUserID(user.Id); } - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(emp.Id); + List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeId(emp.Id); string[] projectsId = []; /* User with permission manage project can see all projects */ diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 589ab52..4369b5b 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -1,5 +1,6 @@ using Marco.Pms.CacheHelper; using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.Master; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using MarcoBMS.Services.Service; @@ -15,20 +16,20 @@ namespace Marco.Pms.Services.Helpers private readonly ReportCache _reportCache; private readonly ILoggingService _logger; private readonly IDbContextFactory _dbContextFactory; + private readonly ApplicationDbContext _context; public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ReportCache reportCache, ILoggingService logger, - IDbContextFactory dbContextFactory) + IDbContextFactory dbContextFactory, ApplicationDbContext context) { _projectCache = projectCache; _employeeCache = employeeCache; _reportCache = reportCache; _logger = logger; _dbContextFactory = dbContextFactory; + _context = context; } // ------------------------------------ Project Details Cache --------------------------------------- - // Assuming you have access to an IDbContextFactory as _dbContextFactory - // This is crucial for safe parallel database operations. public async Task AddProjectDetails(Project project) { @@ -417,9 +418,11 @@ namespace Marco.Pms.Services.Helpers } public async Task UpdateProjectDetailsOnly(Project project) { + StatusMaster projectStatus = await _context.StatusMasters + .FirstOrDefaultAsync(s => s.Id == project.ProjectStatusId) ?? new StatusMaster(); try { - bool response = await _projectCache.UpdateProjectDetailsOnlyToCache(project); + bool response = await _projectCache.UpdateProjectDetailsOnlyToCache(project, projectStatus); return response; } catch (Exception ex) @@ -457,10 +460,22 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogWarning("Error occured while getting list od project details from to Cache: {Error}", ex.Message); + _logger.LogWarning("Error occured while getting list of project details from to Cache: {Error}", ex.Message); return null; } } + public async Task DeleteProjectByIdAsync(Guid projectId) + { + try + { + var response = await _projectCache.DeleteProjectByIdFromCacheAsync(projectId); + } + catch (Exception ex) + { + _logger.LogWarning("Error occured while deleting project from to Cache: {Error}", ex.Message); + + } + } // ------------------------------------ Project Infrastructure Cache --------------------------------------- @@ -527,6 +542,9 @@ namespace Marco.Pms.Services.Helpers return null; } } + + // ------------------------------------------------------- WorkItem ------------------------------------------------------- + public async Task?> GetWorkItemsByWorkAreaIds(List workAreaIds) { try @@ -544,9 +562,6 @@ namespace Marco.Pms.Services.Helpers return null; } } - - // ------------------------------------------------------- WorkItem ------------------------------------------------------- - public async Task ManageWorkItemDetails(List workItems) { try @@ -609,6 +624,18 @@ namespace Marco.Pms.Services.Helpers _logger.LogWarning("Error occured while updating planned work, completed work, and today's assigned work in workItems in Cache: {Error}", ex.Message); } } + public async Task DeleteWorkItemByIdAsync(Guid workItemId) + { + try + { + var response = await _projectCache.DeleteWorkItemByIdFromCacheAsync(workItemId); + } + catch (Exception ex) + { + _logger.LogWarning("Error occured while deleting work item from to Cache: {Error}", ex.Message); + + } + } // ------------------------------------ Employee Profile Cache --------------------------------------- diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs index fb5b6f2..6c1cab1 100644 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ b/Marco.Pms.Services/Helpers/ProjectsHelper.cs @@ -58,7 +58,7 @@ namespace MarcoBMS.Services.Helpers if (projectIds == null) { - var hasPermission = await _permission.HasPermission(LoggedInEmployee.Id, PermissionsMaster.ManageProject); + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, LoggedInEmployee.Id); if (hasPermission) { var projects = await _context.Projects.Where(c => c.TenantId == tenantId).ToListAsync(); diff --git a/Marco.Pms.Services/Helpers/RolesHelper.cs b/Marco.Pms.Services/Helpers/RolesHelper.cs index 15bf0b1..1688dce 100644 --- a/Marco.Pms.Services/Helpers/RolesHelper.cs +++ b/Marco.Pms.Services/Helpers/RolesHelper.cs @@ -3,6 +3,7 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Entitlements; using Marco.Pms.Services.Helpers; +using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; namespace MarcoBMS.Services.Helpers @@ -11,33 +12,81 @@ namespace MarcoBMS.Services.Helpers { private readonly ApplicationDbContext _context; private readonly CacheUpdateHelper _cache; - public RolesHelper(ApplicationDbContext context, CacheUpdateHelper cache) + private readonly ILoggingService _logger; + public RolesHelper(ApplicationDbContext context, CacheUpdateHelper cache, ILoggingService logger) { _context = context; _cache = cache; + _logger = logger; } - public async Task> GetFeaturePermissionByEmployeeID(Guid EmployeeID) + /// + /// Retrieves a unique list of enabled feature permissions for a given employee. + /// This method is optimized to use a single, composed database query. + /// + /// The ID of the employee. + /// A distinct list of FeaturePermission objects the employee is granted. + public async Task> GetFeaturePermissionByEmployeeId(Guid EmployeeId) { - List roleMappings = await _context.EmployeeRoleMappings.Where(c => c.EmployeeId == EmployeeID && c.IsEnabled == true).Select(c => c.RoleId).ToListAsync(); + _logger.LogInfo("Fetching feature permissions for EmployeeId: {EmployeeId}", EmployeeId); - await _cache.AddApplicationRole(EmployeeID, roleMappings); + try + { + // --- Step 1: Define the subquery for the employee's roles --- + // This is an IQueryable, not a list. It will be composed directly into the main query + // by Entity Framework, avoiding a separate database call. + var employeeRoleIdsQuery = _context.EmployeeRoleMappings + .Where(erm => erm.EmployeeId == EmployeeId && erm.IsEnabled == true) + .Select(erm => erm.RoleId); - // _context.RolePermissionMappings + // --- Step 2: Asynchronously update the cache in the background (Fire and Forget) --- + // This task is started but not awaited. The main function continues immediately, + // reducing latency. The cache will be updated eventually without blocking the user. + _ = Task.Run(async () => + { + try + { + var roleIds = await employeeRoleIdsQuery.ToListAsync(); // Execute the query for the cache + if (roleIds.Any()) + { + await _cache.AddApplicationRole(EmployeeId, roleIds); + _logger.LogInfo("Successfully queued cache update for EmployeeId: {EmployeeId}", EmployeeId); + } + } + catch (Exception ex) + { + // Log errors from the background task so they are not lost. + _logger.LogWarning("Background cache update failed for EmployeeId {EmployeeId} : {Error}", EmployeeId, ex.Message); + } + }); - var result = await (from rpm in _context.RolePermissionMappings - join fp in _context.FeaturePermissions.Where(c => c.IsEnabled == true).Include(fp => fp.Feature) // Include Feature - on rpm.FeaturePermissionId equals fp.Id - where roleMappings.Contains(rpm.ApplicationRoleId) - select fp) - .ToListAsync(); + // --- Step 3: Execute the main query to get permissions in a single database call --- + // This single, efficient query gets all the required data at once. + var permissions = await ( + from rpm in _context.RolePermissionMappings + join fp in _context.FeaturePermissions.Include(f => f.Feature) // Include related Feature data + on rpm.FeaturePermissionId equals fp.Id + // The 'employeeRoleIdsQuery' subquery is seamlessly integrated here by EF Core, + // resulting in a SQL "IN (SELECT ...)" clause. + where employeeRoleIdsQuery.Contains(rpm.ApplicationRoleId) && fp.IsEnabled == true + select fp) + .Distinct() // Ensures each permission is returned only once + .ToListAsync(); - return result; + _logger.LogInfo("Successfully retrieved {PermissionCount} unique permissions for EmployeeId: {EmployeeId}", permissions.Count, EmployeeId); - // return null; + return permissions; + } + catch (Exception ex) + { + _logger.LogError("An error occurred while fetching permissions for EmployeeId {EmployeeId} :{Error}", EmployeeId, ex.Message); + // Depending on your application's error handling strategy, you might re-throw, + // or return an empty list to prevent downstream failures. + return new List(); + } } - public async Task> GetFeaturePermissionByRoleID(Guid roleId) + public async Task> GetFeaturePermissionByRoleID1(Guid roleId) { List roleMappings = await _context.RolePermissionMappings.Where(c => c.ApplicationRoleId == roleId).Select(c => c.ApplicationRoleId).ToListAsync(); @@ -54,5 +103,49 @@ namespace MarcoBMS.Services.Helpers // return null; } + /// + /// Retrieves a unique list of enabled feature permissions for a given role. + /// This method is optimized to fetch all data in a single, efficient database query. + /// + /// The ID of the role. + /// A distinct list of FeaturePermission objects granted to the role. + public async Task> GetFeaturePermissionByRoleID(Guid roleId) + { + _logger.LogInfo("Fetching feature permissions for RoleID: {RoleId}", roleId); + + try + { + // This single, efficient query gets all the required data at once. + // It joins the mapping table to the permissions table and filters by the given roleId. + var permissions = await ( + // 1. Start with the linking table. + from rpm in _context.RolePermissionMappings + + // 2. Join to the FeaturePermissions table on the foreign key. + join fp in _context.FeaturePermissions on rpm.FeaturePermissionId equals fp.Id + + // 3. Apply all filters in one 'where' clause for clarity and efficiency. + where + rpm.ApplicationRoleId == roleId // Filter by the specific role + && fp.IsEnabled == true // And only get enabled permissions + + // 4. Select the final FeaturePermission object. + select fp) + .Include(fp => fp.Feature) + .Distinct() + .ToListAsync(); + + _logger.LogInfo("Successfully retrieved {PermissionCount} unique permissions for RoleID: {RoleId}", permissions.Count, roleId); + + return permissions; + } + catch (Exception ex) + { + _logger.LogError("An error occurred while fetching permissions for RoleId {RoleId}: {Error}", roleId, ex.Message); + // Return an empty list as a safe default to prevent downstream failures. + return new List(); + } + } + } } diff --git a/Marco.Pms.Services/Service/PermissionServices.cs b/Marco.Pms.Services/Service/PermissionServices.cs index 7162dc5..f20a768 100644 --- a/Marco.Pms.Services/Service/PermissionServices.cs +++ b/Marco.Pms.Services/Service/PermissionServices.cs @@ -24,7 +24,7 @@ namespace Marco.Pms.Services.Service var featurePermissionIds = await _cache.GetPermissions(employeeId); if (featurePermissionIds == null) { - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(employeeId); + List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeId(employeeId); featurePermissionIds = featurePermission.Select(fp => fp.Id).ToList(); } var hasPermission = featurePermissionIds.Contains(featurePermissionId); -- 2.43.0 From 0c84bb11a3f3bfec39e7a31446cc9fca2f528b65 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 14 Jul 2025 15:08:31 +0530 Subject: [PATCH 04/50] Solved Concurrency Issue --- Marco.Pms.CacheHelper/EmployeeCache.cs | 19 +------- .../Helpers/CacheUpdateHelper.cs | 23 +++++++++- Marco.Pms.Services/Helpers/RolesHelper.cs | 43 ++++++++++--------- 3 files changed, 46 insertions(+), 39 deletions(-) diff --git a/Marco.Pms.CacheHelper/EmployeeCache.cs b/Marco.Pms.CacheHelper/EmployeeCache.cs index c2a1f7b..4a668f0 100644 --- a/Marco.Pms.CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.CacheHelper/EmployeeCache.cs @@ -20,29 +20,12 @@ namespace Marco.Pms.CacheHelper var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name _collection = mongoDB.GetCollection("EmployeeProfile"); } - public async Task AddApplicationRoleToCache(Guid employeeId, List roleIds) + public async Task AddApplicationRoleToCache(Guid employeeId, List newRoleIds, List newPermissionIds) { - // 1. Guard Clause: Avoid unnecessary database work if there are no roles to add. - if (roleIds == null || !roleIds.Any()) - { - return false; // Nothing to add, so the operation did not result in a change. - } // 2. Perform database queries concurrently for better performance. var employeeIdString = employeeId.ToString(); - Task> getPermissionIdsTask = _context.RolePermissionMappings - .Where(rp => roleIds.Contains(rp.ApplicationRoleId)) - .Select(p => p.FeaturePermissionId.ToString()) - .Distinct() - .ToListAsync(); - - // 3. Prepare role IDs in parallel with the database query. - var newRoleIds = roleIds.Select(r => r.ToString()).ToList(); - - // 4. Await the database query result. - var newPermissionIds = await getPermissionIdsTask; - // 5. Build a single, efficient update operation. var filter = Builders.Filter.Eq(e => e.Id, employeeIdString); diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 4369b5b..5bae90f 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -641,9 +641,30 @@ namespace Marco.Pms.Services.Helpers // ------------------------------------ Employee Profile Cache --------------------------------------- public async Task AddApplicationRole(Guid employeeId, List roleIds) { + // 1. Guard Clause: Avoid unnecessary database work if there are no roles to add. + if (roleIds == null || !roleIds.Any()) + { + return; // Nothing to add, so the operation did not result in a change. + } + Task> getPermissionIdsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + + return await context.RolePermissionMappings + .Where(rp => roleIds.Contains(rp.ApplicationRoleId)) + .Select(p => p.FeaturePermissionId.ToString()) + .Distinct() + .ToListAsync(); + }); + + // 3. Prepare role IDs in parallel with the database query. + var newRoleIds = roleIds.Select(r => r.ToString()).ToList(); + + // 4. Await the database query result. + var newPermissionIds = await getPermissionIdsTask; try { - var response = await _employeeCache.AddApplicationRoleToCache(employeeId, roleIds); + var response = await _employeeCache.AddApplicationRoleToCache(employeeId, newRoleIds, newPermissionIds); } catch (Exception ex) { diff --git a/Marco.Pms.Services/Helpers/RolesHelper.cs b/Marco.Pms.Services/Helpers/RolesHelper.cs index 1688dce..cd73c0f 100644 --- a/Marco.Pms.Services/Helpers/RolesHelper.cs +++ b/Marco.Pms.Services/Helpers/RolesHelper.cs @@ -10,14 +10,16 @@ namespace MarcoBMS.Services.Helpers { public class RolesHelper { + private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly CacheUpdateHelper _cache; private readonly ILoggingService _logger; - public RolesHelper(ApplicationDbContext context, CacheUpdateHelper cache, ILoggingService logger) + public RolesHelper(ApplicationDbContext context, CacheUpdateHelper cache, ILoggingService logger, IDbContextFactory dbContextFactory) { _context = context; _cache = cache; _logger = logger; + _dbContextFactory = dbContextFactory; } /// @@ -32,56 +34,57 @@ namespace MarcoBMS.Services.Helpers try { - // --- Step 1: Define the subquery for the employee's roles --- - // This is an IQueryable, not a list. It will be composed directly into the main query - // by Entity Framework, avoiding a separate database call. + // --- Step 1: Define the subquery using the main thread's context --- + // This is safe because the query is not executed yet. var employeeRoleIdsQuery = _context.EmployeeRoleMappings - .Where(erm => erm.EmployeeId == EmployeeId && erm.IsEnabled == true) + .Where(erm => erm.EmployeeId == EmployeeId && erm.IsEnabled) .Select(erm => erm.RoleId); - // --- Step 2: Asynchronously update the cache in the background (Fire and Forget) --- - // This task is started but not awaited. The main function continues immediately, - // reducing latency. The cache will be updated eventually without blocking the user. + // --- Step 2: Asynchronously update the cache using the DbContextFactory --- _ = Task.Run(async () => { try { - var roleIds = await employeeRoleIdsQuery.ToListAsync(); // Execute the query for the cache + // Create a NEW, short-lived DbContext instance for this background task. + await using var contextForCache = await _dbContextFactory.CreateDbContextAsync(); + + // Now, re-create and execute the query using this new, isolated context. + var roleIds = await contextForCache.EmployeeRoleMappings + .Where(erm => erm.EmployeeId == EmployeeId && erm.IsEnabled) + .Select(erm => erm.RoleId) + .ToListAsync(); + if (roleIds.Any()) { + // The cache service might also need its own context, or you can pass the data directly. + // Assuming AddApplicationRole takes the data, not a context. await _cache.AddApplicationRole(EmployeeId, roleIds); _logger.LogInfo("Successfully queued cache update for EmployeeId: {EmployeeId}", EmployeeId); } } catch (Exception ex) { - // Log errors from the background task so they are not lost. _logger.LogWarning("Background cache update failed for EmployeeId {EmployeeId} : {Error}", EmployeeId, ex.Message); } }); - // --- Step 3: Execute the main query to get permissions in a single database call --- - // This single, efficient query gets all the required data at once. + // --- Step 3: Execute the main query on the main thread using its original context --- + // This is now safe because the background task is using a different DbContext instance. var permissions = await ( from rpm in _context.RolePermissionMappings - join fp in _context.FeaturePermissions.Include(f => f.Feature) // Include related Feature data + join fp in _context.FeaturePermissions.Include(f => f.Feature) on rpm.FeaturePermissionId equals fp.Id - // The 'employeeRoleIdsQuery' subquery is seamlessly integrated here by EF Core, - // resulting in a SQL "IN (SELECT ...)" clause. where employeeRoleIdsQuery.Contains(rpm.ApplicationRoleId) && fp.IsEnabled == true select fp) - .Distinct() // Ensures each permission is returned only once + .Distinct() .ToListAsync(); _logger.LogInfo("Successfully retrieved {PermissionCount} unique permissions for EmployeeId: {EmployeeId}", permissions.Count, EmployeeId); - return permissions; } catch (Exception ex) { - _logger.LogError("An error occurred while fetching permissions for EmployeeId {EmployeeId} :{Error}", EmployeeId, ex.Message); - // Depending on your application's error handling strategy, you might re-throw, - // or return an empty list to prevent downstream failures. + _logger.LogError("An error occurred while fetching permissions for EmployeeId {EmployeeId} : {Error}", EmployeeId, ex.Message); return new List(); } } -- 2.43.0 From c5d9beec04403afda3c809138351dfb117c9f8b7 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 14 Jul 2025 15:57:52 +0530 Subject: [PATCH 05/50] Optimized the Get project By ID API --- .../MongoDBModels/StatusMasterMongoDB.cs | 2 +- .../Controllers/ProjectController.cs | 117 +++++++++++++++--- .../MappingProfiles/ProjectMappingProfile.cs | 13 +- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs b/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs index 01a0552..77e8eb5 100644 --- a/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs +++ b/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs @@ -2,7 +2,7 @@ { public class StatusMasterMongoDB { - public string? Id { get; set; } + public string Id { get; set; } = string.Empty; public string? Status { get; set; } } } diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index adb5887..acc97d2 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -40,8 +40,8 @@ namespace MarcoBMS.Services.Controllers private readonly Guid tenantId; - public ProjectController(IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, RolesHelper rolesHelper, - ProjectsHelper projectHelper, IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IMapper mapper) + public ProjectController(IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, + ProjectsHelper projectHelper, IHubContext signalR, CacheUpdateHelper cache, PermissionServices permission, IMapper mapper) { _dbContextFactory = dbContextFactory; _context = context; @@ -52,7 +52,7 @@ namespace MarcoBMS.Services.Controllers _cache = cache; _permission = permission; _mapper = mapper; - tenantId = _userHelper.GetTenantId(); + tenantId = userHelper.GetTenantId(); } #region =================================================================== Project Get APIs =================================================================== @@ -161,29 +161,74 @@ namespace MarcoBMS.Services.Controllers catch (Exception ex) { // --- Step 6: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. : {Error}", tenantId, ex.Message); + _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. \n {Error}", tenantId, ex.Message); return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500)); } } + /// + /// Retrieves details for a specific project by its ID. + /// This endpoint is optimized with a cache-first strategy and parallel permission checks. + /// + /// The unique identifier of the project. + /// An ApiResponse containing the project details or an appropriate error. + [HttpGet("get/{id}")] public async Task Get([FromRoute] Guid id) { + // --- Step 1: Input Validation --- if (!ModelState.IsValid) { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get project called with invalid model state for ID {ProjectId}. Errors: {Errors}", id, string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - var project = await _context.Projects.Where(c => c.TenantId == _userHelper.GetTenantId() && c.Id == id).SingleOrDefaultAsync(); - if (project == null) return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); - return Ok(ApiResponse.SuccessResponse(project, "Success.", 200)); + try + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // --- Step 2: Run independent operations in PARALLEL --- + // We can check permissions and fetch data at the same time to reduce latency. + var permissionTask = _permission.HasProjectPermission(loggedInEmployee, id); + + // This helper method encapsulates the "cache-first, then database" logic. + var projectDataTask = GetProjectDataAsync(id); + + // Await both tasks to complete. + await Task.WhenAll(permissionTask, projectDataTask); + + var hasPermission = await permissionTask; + var projectVm = await projectDataTask; + + // --- Step 3: Process results sequentially --- + + // 3a. Check for permission first. Forbid() is the idiomatic way to return 403. + if (!hasPermission) + { + _logger.LogWarning("Access denied for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, id); + return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to access this project.", 403))); + } + + // 3b. Check if the project was found (either in cache or DB). + if (projectVm == null) + { + _logger.LogInfo("Project with ID {ProjectId} not found.", id); + return NotFound(ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404)); + } + + // 3c. Success. Return the consistent ViewModel. + _logger.LogInfo("Successfully retrieved project {ProjectId}.", id); + return Ok(ApiResponse.SuccessResponse(projectVm, "Project retrieved successfully.", 200)); + } + catch (Exception ex) + { + _logger.LogError("An unexpected error occurred while getting project {ProjectId} : \n {Error}", id, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); + } } + [HttpGet("details/{id}")] public async Task Details([FromRoute] Guid id) { @@ -1331,7 +1376,6 @@ namespace MarcoBMS.Services.Controllers return vm; } - /// /// Fetches project details from the database for a given list of project IDs and assembles them into MongoDB models. /// This method encapsulates the optimized, parallel database queries. @@ -1409,6 +1453,51 @@ namespace MarcoBMS.Services.Controllers return mongoDetailsList; } + /// + /// Private helper to encapsulate the cache-first data retrieval logic. + /// + /// A ProjectDetailVM if found, otherwise null. + private async Task GetProjectDataAsync(Guid projectId) + { + // --- Cache First --- + _logger.LogDebug("Attempting to fetch project {ProjectId} from cache.", projectId); + var cachedProject = await _cache.GetProjectDetails(projectId); + if (cachedProject != null) + { + _logger.LogInfo("Cache HIT for project {ProjectId}.", projectId); + // Map from the cache model (e.g., ProjectMongoDB) to the response ViewModel. + return _mapper.Map(cachedProject); + } + + // --- Database Second (on Cache Miss) --- + _logger.LogInfo("Cache MISS for project {ProjectId}. Fetching from database.", projectId); + var dbProject = await _context.Projects + .AsNoTracking() // Use AsNoTracking for read-only queries. + .Where(p => p.Id == projectId && p.TenantId == tenantId) + .SingleOrDefaultAsync(); + + if (dbProject == null) + { + return null; // The project doesn't exist. + } + + // --- Proactively Update Cache --- + // The next request for this project will now be a cache hit. + try + { + // Map the DB entity to the cache model (e.g., ProjectMongoDB) before caching. + await _cache.AddProjectDetails(dbProject); + _logger.LogInfo("Updated cache with project {ProjectId}.", projectId); + } + catch (Exception ex) + { + _logger.LogWarning("Failed to update cache for project {ProjectId} : \n {Error}", projectId, ex.Message); + } + + // Map from the database entity to the response ViewModel. + return dbProject; + } + #endregion } } \ No newline at end of file diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs index c7ec4af..f527f67 100644 --- a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs @@ -20,7 +20,18 @@ namespace Marco.Pms.Services.MappingProfiles .ForMember( dest => dest.Id, // Explicitly and safely convert string Id to Guid Id - opt => opt.MapFrom(src => src.Id == null ? Guid.Empty : new Guid(src.Id)) + opt => opt.MapFrom(src => new Guid(src.Id)) + ); + + CreateMap() + .ForMember( + dest => dest.Id, + // Explicitly and safely convert string Id to Guid Id + opt => opt.MapFrom(src => new Guid(src.Id)) + ).ForMember( + dest => dest.ProjectStatusId, + // Explicitly and safely convert string ProjectStatusId to Guid ProjectStatusId + opt => opt.MapFrom(src => src.ProjectStatus == null ? Guid.Empty : new Guid(src.ProjectStatus.Id)) ); CreateMap(); -- 2.43.0 From e769c161f47b8dc8c0ae10e17a7656ba1906e470 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 14 Jul 2025 17:00:28 +0530 Subject: [PATCH 06/50] Optimized the Update project API --- .../Controllers/ProjectController.cs | 168 ++++++++++++++---- Marco.Pms.Services/Helpers/ProjectsHelper.cs | 6 +- .../MappingProfiles/ProjectMappingProfile.cs | 3 + 3 files changed, 142 insertions(+), 35 deletions(-) diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index acc97d2..3d5558f 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -70,7 +70,6 @@ namespace MarcoBMS.Services.Controllers _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); // Step 2: Get the list of project IDs the user has access to - Guid tenantId = _userHelper.GetTenantId(); // Assuming this is still needed by the helper List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); if (accessibleProjectIds == null || !accessibleProjectIds.Any()) @@ -316,7 +315,7 @@ namespace MarcoBMS.Services.Controllers } - var project = await _context.Projects.Where(c => c.TenantId == _userHelper.GetTenantId() && c.Id == id).Include(c => c.ProjectStatus).SingleOrDefaultAsync(); // includeProperties: "ProjectStatus,Tenant"); //_context.Stock.FindAsync(id); + var project = await _context.Projects.Where(c => c.TenantId == tenantId && c.Id == id).Include(c => c.ProjectStatus).SingleOrDefaultAsync(); // includeProperties: "ProjectStatus,Tenant"); //_context.Stock.FindAsync(id); if (project == null) { @@ -420,7 +419,6 @@ namespace MarcoBMS.Services.Controllers } // 2. Prepare data without I/O - Guid tenantId = _userHelper.GetTenantId(); // Assuming this is fast and from claims Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var loggedInUserId = loggedInEmployee.Id; var project = projectDto.ToProjectFromCreateProjectDto(tenantId); @@ -465,7 +463,7 @@ namespace MarcoBMS.Services.Controllers } [HttpPut] - [Route("update/{id}")] + [Route("update1/{id}")] public async Task Update([FromRoute] Guid id, [FromBody] UpdateProjectDto updateProjectDto) { var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); @@ -480,9 +478,7 @@ namespace MarcoBMS.Services.Controllers } try { - Guid TenantId = GetTenantId(); - - Project project = updateProjectDto.ToProjectFromUpdateProjectDto(TenantId, id); + Project project = updateProjectDto.ToProjectFromUpdateProjectDto(tenantId, id); _context.Projects.Update(project); await _context.SaveChangesAsync(); @@ -507,6 +503,97 @@ namespace MarcoBMS.Services.Controllers } } + /// + /// Updates an existing project's details. + /// This endpoint is secure, handles concurrency, and performs non-essential tasks in the background. + /// + /// The ID of the project to update. + /// The data to update the project with. + /// An ApiResponse confirming the update or an appropriate error. + + [HttpPut("update/{id}")] + public async Task UpdateProject([FromRoute] Guid id, [FromBody] UpdateProjectDto updateProjectDto) + { + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) + { + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Update project called with invalid model state for ID {ProjectId}. Errors: {Errors}", id, string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } + + try + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + // --- Step 2: Fetch the Existing Entity from the Database --- + // This is crucial to avoid the data loss bug. We only want to modify an existing record. + var existingProject = await _context.Projects + .Where(p => p.Id == id && p.TenantId == tenantId) + .SingleOrDefaultAsync(); + + // 2a. Existence Check + if (existingProject == null) + { + _logger.LogWarning("Attempt to update non-existent project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); + return NotFound(ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404)); + } + + // 2b. Security Check + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} attempting to update project {ProjectId}.", loggedInEmployee.Id, id); + return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to modify this project.", 403))); + } + + // --- Step 3: Apply Changes and Save --- + // Map the changes from the DTO onto the entity we just fetched from the database. + // This only modifies the properties defined in the mapping, preventing data loss. + _mapper.Map(updateProjectDto, existingProject); + + // Mark the entity as modified (if your mapping doesn't do it automatically). + _context.Entry(existingProject).State = EntityState.Modified; + + try + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully updated project {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); + } + catch (DbUpdateConcurrencyException ex) + { + // --- Step 4: Handle Concurrency Conflicts --- + // This happens if another user modified the project after we fetched it. + _logger.LogWarning("Concurrency conflict while updating project {ProjectId} \n {Error}", id, ex.Message); + return Conflict(ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409)); + } + + // --- Step 5: Perform Side-Effects in the Background (Fire and Forget) --- + // The core database operation is done. Now, we perform non-blocking cache and notification updates. + _ = Task.Run(async () => + { + // Create a DTO of the updated project to pass to background tasks. + var projectDto = _mapper.Map(existingProject); + + // 5a. Update Cache + await UpdateCacheInBackground(existingProject); + + // 5b. Send Targeted SignalR Notification + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Update_Project", Response = projectDto }; + await SendNotificationInBackground(notification, projectDto.Id); + }); + + // --- Step 6: Return Success Response Immediately --- + // The client gets a fast response without waiting for caching or SignalR. + return Ok(ApiResponse.SuccessResponse(_mapper.Map(existingProject), "Project updated successfully.", 200)); + } + catch (Exception ex) + { + // --- Step 7: Graceful Error Handling for Unexpected Errors --- + _logger.LogError("An unexpected error occurred while updating project {ProjectId} \n {Error}", id, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); + } + } + #endregion #region =================================================================== Project Allocation APIs =================================================================== @@ -524,7 +611,6 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } - Guid TenantId = GetTenantId(); if (projectid != null) { @@ -535,14 +621,14 @@ namespace MarcoBMS.Services.Controllers { result = await (from rpm in _context.Employees.Include(c => c.JobRole) - join fp in _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == projectid) + join fp in _context.ProjectAllocations.Where(c => c.TenantId == tenantId && c.ProjectId == projectid) on rpm.Id equals fp.EmployeeId select rpm).ToListAsync(); } else { result = await (from rpm in _context.Employees.Include(c => c.JobRole) - join fp in _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == projectid && c.IsActive == true) + join fp in _context.ProjectAllocations.Where(c => c.TenantId == tenantId && c.ProjectId == projectid && c.IsActive) on rpm.Id equals fp.EmployeeId select rpm).ToListAsync(); } @@ -577,11 +663,9 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } - Guid TenantId = GetTenantId(); - var employees = await _context.ProjectAllocations - .Where(c => c.TenantId == TenantId && c.ProjectId == projectId && c.Employee != null) + .Where(c => c.TenantId == tenantId && c.ProjectId == projectId && c.Employee != null) .Include(e => e.Employee) .Select(e => new { @@ -605,7 +689,6 @@ namespace MarcoBMS.Services.Controllers { if (projectAllocationDot != null) { - Guid TenentID = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); List? result = new List(); @@ -616,11 +699,11 @@ namespace MarcoBMS.Services.Controllers { try { - ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(TenentID); + ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == projectAllocation.EmployeeId && c.ProjectId == projectAllocation.ProjectId && c.ReAllocationDate == null - && c.TenantId == TenentID).SingleOrDefaultAsync(); + && c.TenantId == tenantId).SingleOrDefaultAsync(); if (projectAllocationFromDb != null) { @@ -688,8 +771,6 @@ namespace MarcoBMS.Services.Controllers [HttpGet("assigned-projects/{employeeId}")] public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) { - - Guid tenantId = _userHelper.GetTenantId(); if (employeeId == Guid.Empty) { return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Employee id not valid.", 400)); @@ -729,7 +810,6 @@ namespace MarcoBMS.Services.Controllers { if (projectAllocationDtos != null && employeeId != Guid.Empty) { - Guid TenentID = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); List? result = new List(); List projectIds = new List(); @@ -738,8 +818,8 @@ namespace MarcoBMS.Services.Controllers { try { - ProjectAllocation projectAllocation = projectAllocationDto.ToProjectAllocationFromProjectsAllocationDto(TenentID, employeeId); - ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.ProjectId == projectAllocationDto.ProjectId && c.ReAllocationDate == null && c.TenantId == TenentID).SingleOrDefaultAsync(); + ProjectAllocation projectAllocation = projectAllocationDto.ToProjectAllocationFromProjectsAllocationDto(tenantId, employeeId); + ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.ProjectId == projectAllocationDto.ProjectId && c.ReAllocationDate == null && c.TenantId == tenantId).SingleOrDefaultAsync(); if (projectAllocationFromDb != null) { @@ -1017,7 +1097,6 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Work Item details are not valid.", 400)); } - Guid tenantId = GetTenantId(); var workItemsToCreate = new List(); var workItemsToUpdate = new List(); var responseList = new List(); @@ -1113,7 +1192,6 @@ namespace MarcoBMS.Services.Controllers [HttpDelete("task/{id}")] public async Task DeleteProjectTask(Guid id) { - Guid tenantId = _userHelper.GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); List workAreaIds = new List(); WorkItem? task = await _context.WorkItems.AsNoTracking().Include(t => t.WorkArea).FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); @@ -1162,7 +1240,6 @@ namespace MarcoBMS.Services.Controllers [HttpPost("manage-infra")] public async Task ManageProjectInfra(List infraDots) { - Guid tenantId = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var responseData = new InfraVM { }; @@ -1177,7 +1254,7 @@ namespace MarcoBMS.Services.Controllers { Building building = item.Building.ToBuildingFromBuildingDto(tenantId); - building.TenantId = GetTenantId(); + building.TenantId = tenantId; if (item.Building.Id == null) { @@ -1204,7 +1281,7 @@ namespace MarcoBMS.Services.Controllers if (item.Floor != null) { Floor floor = item.Floor.ToFloorFromFloorDto(tenantId); - floor.TenantId = GetTenantId(); + floor.TenantId = tenantId; bool isCreated = false; if (item.Floor.Id == null) @@ -1242,7 +1319,7 @@ namespace MarcoBMS.Services.Controllers if (item.WorkArea != null) { WorkArea workArea = item.WorkArea.ToWorkAreaFromWorkAreaDto(tenantId); - workArea.TenantId = GetTenantId(); + workArea.TenantId = tenantId; bool isCreated = false; if (item.WorkArea.Id == null) @@ -1343,11 +1420,6 @@ namespace MarcoBMS.Services.Controllers return finalViewModels; } - private Guid GetTenantId() - { - return _userHelper.GetTenantId(); - } - private async Task GetProjectViewModel(Guid? id, Project project) { ProjectDetailsVM vm = new ProjectDetailsVM(); @@ -1498,6 +1570,38 @@ namespace MarcoBMS.Services.Controllers return dbProject; } + // Helper method for background cache update + private async Task UpdateCacheInBackground(Project project) + { + try + { + // This logic can be more complex, but the idea is to update or add. + if (!await _cache.UpdateProjectDetailsOnly(project)) + { + await _cache.AddProjectDetails(project); + } + _logger.LogInfo("Background cache update succeeded for project {ProjectId}.", project.Id); + } + catch (Exception ex) + { + _logger.LogError("Background cache update failed for project {ProjectId} \n {Error}", project.Id, ex.Message); + } + } + + // Helper method for background notification + private async Task SendNotificationInBackground(object notification, Guid projectId) + { + try + { + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + _logger.LogInfo("Background SignalR notification sent for project {ProjectId}.", projectId); + } + catch (Exception ex) + { + _logger.LogError("Background SignalR notification failed for project {ProjectId} \n {Error}", projectId, ex.Message); + } + } + #endregion } } \ No newline at end of file diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs index 6c1cab1..fe70a0a 100644 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ b/Marco.Pms.Services/Helpers/ProjectsHelper.cs @@ -67,11 +67,11 @@ namespace MarcoBMS.Services.Helpers else { var allocation = await GetProjectByEmployeeID(LoggedInEmployee.Id); - if (allocation.Any()) + if (!allocation.Any()) { - projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); + return new List(); } - return new List(); + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); } await _cache.AddProjects(LoggedInEmployee.Id, projectIds); } diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs index f527f67..18db7ff 100644 --- a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Master; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; @@ -14,7 +15,9 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); CreateMap(); + CreateMap(); CreateMap(); + CreateMap(); CreateMap(); CreateMap() .ForMember( -- 2.43.0 From 5de59f0292c639cd75827511b983a18451758ec2 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 14 Jul 2025 18:45:23 +0530 Subject: [PATCH 07/50] Refactored: Moved business logic from ProjectController to ProjectService --- .../Controllers/ProjectController.cs | 693 +----------------- .../MappingProfiles/ProjectMappingProfile.cs | 1 + Marco.Pms.Services/Program.cs | 10 + .../Service/PermissionServices.cs | 10 +- Marco.Pms.Services/Service/ProjectServices.cs | 691 +++++++++++++++++ .../ServiceInterfaces/IProjectServices.cs | 17 + 6 files changed, 760 insertions(+), 662 deletions(-) create mode 100644 Marco.Pms.Services/Service/ProjectServices.cs create mode 100644 Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 3d5558f..e7d257f 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,6 +1,4 @@ -using AutoMapper; -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Activities; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; @@ -13,6 +11,7 @@ using Marco.Pms.Model.ViewModels.Projects; 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; @@ -28,30 +27,26 @@ namespace MarcoBMS.Services.Controllers [Authorize] public class ProjectController : ControllerBase { - private readonly IDbContextFactory _dbContextFactory; + private readonly IProjectServices _projectServices; private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly ILoggingService _logger; - private readonly ProjectsHelper _projectsHelper; private readonly IHubContext _signalR; private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; - private readonly IMapper _mapper; private readonly Guid tenantId; - public ProjectController(IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, - ProjectsHelper projectHelper, IHubContext signalR, CacheUpdateHelper cache, PermissionServices permission, IMapper mapper) + public ProjectController(ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, + IHubContext signalR, CacheUpdateHelper cache, PermissionServices permission, IProjectServices projectServices) { - _dbContextFactory = dbContextFactory; _context = context; _userHelper = userHelper; _logger = logger; - _projectsHelper = projectHelper; _signalR = signalR; _cache = cache; _permission = permission; - _mapper = mapper; + _projectServices = projectServices; tenantId = userHelper.GetTenantId(); } @@ -60,30 +55,10 @@ namespace MarcoBMS.Services.Controllers [HttpGet("list/basic")] public async Task GetAllProjectsBasic() { - // Step 1: Get the current user + // Get the current user var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - if (loggedInEmployee == null) - { - return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "User could not be identified.", 401)); - } - - _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); - - // Step 2: Get the list of project IDs the user has access to - List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - - if (accessibleProjectIds == null || !accessibleProjectIds.Any()) - { - _logger.LogInfo("No accessible projects found for EmployeeId {EmployeeId}", loggedInEmployee.Id); - return Ok(ApiResponse>.SuccessResponse(new List(), "Success.", 200)); - } - - // Step 3: Fetch project ViewModels using the optimized, cache-aware helper - var projectVMs = await GetProjectInfosByIdsAsync(accessibleProjectIds); - - // Step 4: Return the final list - _logger.LogInfo("Successfully returned {ProjectCount} projects for EmployeeId {EmployeeId}", projectVMs.Count, loggedInEmployee.Id); - return Ok(ApiResponse>.SuccessResponse(projectVMs, $"{projectVMs.Count} records of project fetchd successfully", 200)); + var response = await _projectServices.GetAllProjectsBasicAsync(tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } /// @@ -96,7 +71,7 @@ namespace MarcoBMS.Services.Controllers [HttpGet("list")] public async Task GetAllProjects() { - // --- Step 1: Input Validation and Initial Setup --- + // --- Input Validation and Initial Setup --- if (!ModelState.IsValid) { var errors = ModelState.Values @@ -106,63 +81,9 @@ namespace MarcoBMS.Services.Controllers _logger.LogWarning("GetAllProjects called with invalid model state. Errors: {Errors}", string.Join(", ", errors)); return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - - try - { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - _logger.LogInfo("Starting GetAllProjects for TenantId: {TenantId}, User: {UserId}", tenantId, loggedInEmployee.Id); - - // --- Step 2: Get a list of project IDs the user can access --- - List projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - if (!projectIds.Any()) - { - _logger.LogInfo("User has no assigned projects. Returning empty list."); - return Ok(ApiResponse>.SuccessResponse(new List(), "No projects found for the current user.", 200)); - } - - // --- Step 3: Efficiently handle partial cache hits --- - _logger.LogInfo("Attempting to fetch details for {ProjectCount} projects from cache.", projectIds.Count); - - // Fetch what we can from the cache. - var cachedDetails = await _cache.GetProjectDetailsList(projectIds) ?? new List(); - var cachedDictionary = cachedDetails.ToDictionary(p => Guid.Parse(p.Id)); - - // Identify which projects are missing from the cache. - var missingIds = projectIds.Where(id => !cachedDictionary.ContainsKey(id)).ToList(); - - // Start building the response with the items we found in the cache. - var responseVms = _mapper.Map>(cachedDictionary.Values); - - if (missingIds.Any()) - { - // --- Step 4: Fetch ONLY the missing items from the database --- - _logger.LogInfo("Cache partial MISS. Found {CachedCount}, fetching {MissingCount} projects from DB.", - cachedDictionary.Count, missingIds.Count); - - // Call our dedicated data-fetching method for the missing IDs. - var newMongoDetails = await FetchAndBuildProjectDetails(missingIds, tenantId); - - if (newMongoDetails.Any()) - { - // Map the newly fetched items and add them to our response list. - responseVms.AddRange(newMongoDetails); - } - } - else - { - _logger.LogInfo("Cache HIT. All {ProjectCount} projects found in cache.", projectIds.Count); - } - - // --- Step 5: Return the combined result --- - _logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", responseVms.Count); - return Ok(ApiResponse>.SuccessResponse(responseVms, "Projects retrieved successfully.", 200)); - } - catch (Exception ex) - { - // --- Step 6: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. \n {Error}", tenantId, ex.Message); - return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500)); - } + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetAllProjectsAsync(tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } /// @@ -173,7 +94,7 @@ namespace MarcoBMS.Services.Controllers /// An ApiResponse containing the project details or an appropriate error. [HttpGet("get/{id}")] - public async Task Get([FromRoute] Guid id) + public async Task GetProject([FromRoute] Guid id) { // --- Step 1: Input Validation --- if (!ModelState.IsValid) @@ -183,53 +104,14 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - try - { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // --- Step 2: Run independent operations in PARALLEL --- - // We can check permissions and fetch data at the same time to reduce latency. - var permissionTask = _permission.HasProjectPermission(loggedInEmployee, id); - - // This helper method encapsulates the "cache-first, then database" logic. - var projectDataTask = GetProjectDataAsync(id); - - // Await both tasks to complete. - await Task.WhenAll(permissionTask, projectDataTask); - - var hasPermission = await permissionTask; - var projectVm = await projectDataTask; - - // --- Step 3: Process results sequentially --- - - // 3a. Check for permission first. Forbid() is the idiomatic way to return 403. - if (!hasPermission) - { - _logger.LogWarning("Access denied for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, id); - return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to access this project.", 403))); - } - - // 3b. Check if the project was found (either in cache or DB). - if (projectVm == null) - { - _logger.LogInfo("Project with ID {ProjectId} not found.", id); - return NotFound(ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404)); - } - - // 3c. Success. Return the consistent ViewModel. - _logger.LogInfo("Successfully retrieved project {ProjectId}.", id); - return Ok(ApiResponse.SuccessResponse(projectVm, "Project retrieved successfully.", 200)); - } - catch (Exception ex) - { - _logger.LogError("An unexpected error occurred while getting project {ProjectId} : \n {Error}", id, ex.Message); - return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); - } + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectAsync(id, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpGet("details/{id}")] - public async Task Details([FromRoute] Guid id) + public async Task GetProjectDetails([FromRoute] Guid id) { // Step 1: Validate model state if (!ModelState.IsValid) @@ -245,63 +127,13 @@ namespace MarcoBMS.Services.Controllers // Step 2: Get logged-in employee var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - _logger.LogInfo("Details requested by EmployeeId: {EmployeeId} for ProjectId: {ProjectId}", loggedInEmployee.Id, id); - // Step 3: Check global view project permission - var hasViewProjectPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); - if (!hasViewProjectPermission) - { - _logger.LogWarning("ViewProjects permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have permission to view projects", 403)); - } - - // Step 4: Check permission for this specific project - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, id); - if (!hasProjectPermission) - { - _logger.LogWarning("Project-specific access denied. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployee.Id, id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403)); - } - - // Step 5: Fetch project with status - var projectDetails = await _cache.GetProjectDetails(id); - ProjectVM? projectVM = null; - if (projectDetails == null) - { - var project = await _context.Projects - .Include(c => c.ProjectStatus) - .FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id); - - projectVM = _mapper.Map(project); - - if (project != null) - { - await _cache.AddProjectDetails(project); - } - } - else - { - projectVM = _mapper.Map(projectDetails); - if (projectVM.ProjectStatus != null) - { - projectVM.ProjectStatus.TenantId = tenantId; - } - } - - if (projectVM == null) - { - _logger.LogWarning("Project not found. ProjectId: {ProjectId}", id); - return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); - } - - // Step 6: Return result - - _logger.LogInfo("Project details fetched successfully. ProjectId: {ProjectId}", id); - return Ok(ApiResponse.SuccessResponse(projectVM, "Project details fetched successfully", 200)); + var response = await _projectServices.GetProjectDetailsAsync(id, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpGet("details-old/{id}")] - public async Task DetailsOld([FromRoute] Guid id) + public async Task GetProjectDetailsOld([FromRoute] Guid id) { // ProjectDetailsVM vm = new ProjectDetailsVM(); @@ -315,92 +147,10 @@ namespace MarcoBMS.Services.Controllers } - var project = await _context.Projects.Where(c => c.TenantId == tenantId && c.Id == id).Include(c => c.ProjectStatus).SingleOrDefaultAsync(); // includeProperties: "ProjectStatus,Tenant"); //_context.Stock.FindAsync(id); - - if (project == null) - { - return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); - - } - else - { - //var project = projects.Where(c => c.Id == id).SingleOrDefault(); - ProjectDetailsVM vm = await GetProjectViewModel(id, project); - - OldProjectVM projectVM = new OldProjectVM(); - if (vm.project != null) - { - projectVM.Id = vm.project.Id; - projectVM.Name = vm.project.Name; - projectVM.ShortName = vm.project.ShortName; - projectVM.ProjectAddress = vm.project.ProjectAddress; - projectVM.ContactPerson = vm.project.ContactPerson; - projectVM.StartDate = vm.project.StartDate; - projectVM.EndDate = vm.project.EndDate; - projectVM.ProjectStatusId = vm.project.ProjectStatusId; - } - projectVM.Buildings = new List(); - if (vm.buildings != null) - { - foreach (Building build in vm.buildings) - { - BuildingVM buildVM = new BuildingVM() { Id = build.Id, Description = build.Description, Name = build.Name }; - buildVM.Floors = new List(); - if (vm.floors != null) - { - foreach (Floor floorDto in vm.floors.Where(c => c.BuildingId == build.Id).ToList()) - { - FloorsVM floorVM = new FloorsVM() { FloorName = floorDto.FloorName, Id = floorDto.Id }; - floorVM.WorkAreas = new List(); - - if (vm.workAreas != null) - { - foreach (WorkArea workAreaDto in vm.workAreas.Where(c => c.FloorId == floorVM.Id).ToList()) - { - WorkAreaVM workAreaVM = new WorkAreaVM() { Id = workAreaDto.Id, AreaName = workAreaDto.AreaName, WorkItems = new List() }; - - if (vm.workItems != null) - { - foreach (WorkItem workItemDto in vm.workItems.Where(c => c.WorkAreaId == workAreaDto.Id).ToList()) - { - WorkItemVM workItemVM = new WorkItemVM() { WorkItemId = workItemDto.Id, WorkItem = workItemDto }; - - workItemVM.WorkItem.WorkArea = new WorkArea(); - - if (workItemVM.WorkItem.ActivityMaster != null) - { - workItemVM.WorkItem.ActivityMaster.Tenant = new Tenant(); - } - workItemVM.WorkItem.Tenant = new Tenant(); - - double todaysAssigned = 0; - if (vm.Tasks != null) - { - var tasks = vm.Tasks.Where(t => t.WorkItemId == workItemDto.Id).ToList(); - foreach (TaskAllocation task in tasks) - { - todaysAssigned += task.PlannedTask; - } - } - workItemVM.TodaysAssigned = todaysAssigned; - - workAreaVM.WorkItems.Add(workItemVM); - } - } - - floorVM.WorkAreas.Add(workAreaVM); - } - } - - buildVM.Floors.Add(floorVM); - } - } - projectVM.Buildings.Add(buildVM); - } - } - return Ok(ApiResponse.SuccessResponse(projectVM, "Success.", 200)); - } + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectDetailsAsync(id, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } @@ -409,7 +159,7 @@ namespace MarcoBMS.Services.Controllers #region =================================================================== Project Manage APIs =================================================================== [HttpPost] - public async Task Create([FromBody] CreateProjectDto projectDto) + public async Task CreateProject([FromBody] CreateProjectDto projectDto) { // 1. Validate input first (early exit) if (!ModelState.IsValid) @@ -420,87 +170,13 @@ namespace MarcoBMS.Services.Controllers // 2. Prepare data without I/O Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var loggedInUserId = loggedInEmployee.Id; - var project = projectDto.ToProjectFromCreateProjectDto(tenantId); - - // 3. Store it to database - try + var response = await _projectServices.CreateProjectAsync(projectDto, tenantId, loggedInEmployee); + if (response.Success) { - _context.Projects.Add(project); - await _context.SaveChangesAsync(); - } - catch (Exception ex) - { - // Log the detailed exception - _logger.LogError("Failed to create project in database. Rolling back transaction. : {Error}", ex.Message); - // Return a server error as the primary operation failed - return StatusCode(500, ApiResponse.ErrorResponse("An error occurred while saving the project.", ex.Message, 500)); - } - - // 4. Perform non-critical side-effects (caching, notifications) concurrently - try - { - // These operations do not depend on each other, so they can run in parallel. - Task cacheAddDetailsTask = _cache.AddProjectDetails(project); - Task cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(PermissionsMaster.ManageProject); - - var notification = new { LoggedInUserId = loggedInUserId, Keyword = "Create_Project", Response = project.ToProjectDto() }; - // Send notification only to the relevant group (e.g., users in the same tenant) - Task notificationTask = _signalR.Clients.Group(tenantId.ToString()).SendAsync("NotificationEventHandler", notification); - - // Await all side-effect tasks to complete in parallel - await Task.WhenAll(cacheAddDetailsTask, cacheClearListTask, notificationTask); - } - catch (Exception ex) - { - // The project was created successfully, but a side-effect failed. - // Log this as a warning, as the primary operation succeeded. Don't return an error to the user. - _logger.LogWarning("Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. : {Error}", project.Id, ex.Message); - } - - // 5. Return a success response to the user as soon as the critical data is saved. - return Ok(ApiResponse.SuccessResponse(project.ToProjectDto(), "Project created successfully.", 200)); - } - - [HttpPut] - [Route("update1/{id}")] - public async Task Update([FromRoute] Guid id, [FromBody] UpdateProjectDto updateProjectDto) - { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - if (!ModelState.IsValid) - { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - - } - try - { - Project project = updateProjectDto.ToProjectFromUpdateProjectDto(tenantId, id); - _context.Projects.Update(project); - - await _context.SaveChangesAsync(); - - // Cache functions - bool isUpdated = await _cache.UpdateProjectDetailsOnly(project); - if (!isUpdated) - { - await _cache.AddProjectDetails(project); - } - - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Update_Project", Response = project.ToProjectDto() }; - + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Create_Project", Response = response.Data }; await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - - return Ok(ApiResponse.SuccessResponse(project.ToProjectDto(), "Success.", 200)); - - } - catch (Exception ex) - { - return BadRequest(ApiResponse.ErrorResponse(ex.Message, ex, 400)); } + return StatusCode(response.StatusCode, response); } /// @@ -522,76 +198,15 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - try + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.UpdateProjectAsync(id, updateProjectDto, tenantId, loggedInEmployee); + if (response.Success) { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - // --- Step 2: Fetch the Existing Entity from the Database --- - // This is crucial to avoid the data loss bug. We only want to modify an existing record. - var existingProject = await _context.Projects - .Where(p => p.Id == id && p.TenantId == tenantId) - .SingleOrDefaultAsync(); - - // 2a. Existence Check - if (existingProject == null) - { - _logger.LogWarning("Attempt to update non-existent project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); - return NotFound(ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404)); - } - - // 2b. Security Check - var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id); - if (!hasPermission) - { - _logger.LogWarning("Access DENIED for user {UserId} attempting to update project {ProjectId}.", loggedInEmployee.Id, id); - return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to modify this project.", 403))); - } - - // --- Step 3: Apply Changes and Save --- - // Map the changes from the DTO onto the entity we just fetched from the database. - // This only modifies the properties defined in the mapping, preventing data loss. - _mapper.Map(updateProjectDto, existingProject); - - // Mark the entity as modified (if your mapping doesn't do it automatically). - _context.Entry(existingProject).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - _logger.LogInfo("Successfully updated project {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); - } - catch (DbUpdateConcurrencyException ex) - { - // --- Step 4: Handle Concurrency Conflicts --- - // This happens if another user modified the project after we fetched it. - _logger.LogWarning("Concurrency conflict while updating project {ProjectId} \n {Error}", id, ex.Message); - return Conflict(ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409)); - } - - // --- Step 5: Perform Side-Effects in the Background (Fire and Forget) --- - // The core database operation is done. Now, we perform non-blocking cache and notification updates. - _ = Task.Run(async () => - { - // Create a DTO of the updated project to pass to background tasks. - var projectDto = _mapper.Map(existingProject); - - // 5a. Update Cache - await UpdateCacheInBackground(existingProject); - - // 5b. Send Targeted SignalR Notification - var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Update_Project", Response = projectDto }; - await SendNotificationInBackground(notification, projectDto.Id); - }); - - // --- Step 6: Return Success Response Immediately --- - // The client gets a fast response without waiting for caching or SignalR. - return Ok(ApiResponse.SuccessResponse(_mapper.Map(existingProject), "Project updated successfully.", 200)); - } - catch (Exception ex) - { - // --- Step 7: Graceful Error Handling for Unexpected Errors --- - _logger.LogError("An unexpected error occurred while updating project {ProjectId} \n {Error}", id, ex.Message); - return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Update_Project", Response = response.Data }; + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); } + return StatusCode(response.StatusCode, response); } #endregion @@ -1367,241 +982,5 @@ namespace MarcoBMS.Services.Controllers #endregion - #region =================================================================== Helper Functions =================================================================== - - /// - /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. - /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the - /// database (as Project), updates the cache, and returns a unified list of ViewModels. - /// - /// The list of project IDs to retrieve. - /// A list of ProjectInfoVMs. - private async Task> GetProjectInfosByIdsAsync(List projectIds) - { - // --- Step 1: Fetch from Cache --- - // The cache returns a list of MongoDB documents for the projects it found. - var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); - var finalViewModels = _mapper.Map>(cachedMongoDocs); - - _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); - - // --- Step 2: Identify Missing Projects --- - // If we found everything in the cache, we can return early. - if (finalViewModels.Count == projectIds.Count) - { - return finalViewModels; - } - - var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id - var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); - - // --- Step 3: Fetch Missing from Database --- - if (missingIds.Any()) - { - _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); - - var projectsFromDb = await _context.Projects - .Where(p => missingIds.Contains(p.Id)) - .AsNoTracking() // Use AsNoTracking for read-only query performance - .ToListAsync(); - - if (projectsFromDb.Any()) - { - // Map the newly fetched projects (from SQL) to their ViewModel - var vmsFromDb = _mapper.Map>(projectsFromDb); - finalViewModels.AddRange(vmsFromDb); - - // --- Step 4: Update Cache with Missing Items in a new scope --- - _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); - await _cache.AddProjectDetailsList(projectsFromDb); - } - } - - return finalViewModels; - } - - private async Task GetProjectViewModel(Guid? id, Project project) - { - ProjectDetailsVM vm = new ProjectDetailsVM(); - - // List buildings = _unitOfWork.Building.GetAll(c => c.ProjectId == id).ToList(); - List buildings = await _context.Buildings.Where(c => c.ProjectId == id).ToListAsync(); - List idList = buildings.Select(o => o.Id).ToList(); - // List floors = _unitOfWork.Floor.GetAll(c => idList.Contains(c.Id)).ToList(); - List floors = await _context.Floor.Where(c => idList.Contains(c.BuildingId)).ToListAsync(); - idList = floors.Select(o => o.Id).ToList(); - //List workAreas = _unitOfWork.WorkArea.GetAll(c => idList.Contains(c.Id), includeProperties: "WorkItems,WorkItems.ActivityMaster").ToList(); - - List workAreas = await _context.WorkAreas.Where(c => idList.Contains(c.FloorId)).ToListAsync(); - - idList = workAreas.Select(o => o.Id).ToList(); - List workItems = await _context.WorkItems.Include(c => c.WorkCategoryMaster).Where(c => idList.Contains(c.WorkAreaId)).Include(c => c.ActivityMaster).ToListAsync(); - // List workItems = _unitOfWork.WorkItem.GetAll(c => idList.Contains(c.WorkAreaId), includeProperties: "ActivityMaster").ToList(); - idList = workItems.Select(t => t.Id).ToList(); - List tasks = await _context.TaskAllocations.Where(t => idList.Contains(t.WorkItemId) && t.AssignmentDate.Date == DateTime.UtcNow.Date).ToListAsync(); - vm.project = project; - vm.buildings = buildings; - vm.floors = floors; - vm.workAreas = workAreas; - vm.workItems = workItems; - vm.Tasks = tasks; - return vm; - } - - /// - /// Fetches project details from the database for a given list of project IDs and assembles them into MongoDB models. - /// This method encapsulates the optimized, parallel database queries. - /// - /// The list of project IDs to fetch. - /// The current tenant ID for filtering. - /// A list of fully populated ProjectMongoDB objects. - private async Task> FetchAndBuildProjectDetails(List projectIdsToFetch, Guid tenantId) - { - // Task to get base project details for the MISSING projects - var projectsTask = Task.Run(async () => - { - using var context = _dbContextFactory.CreateDbContext(); - return await context.Projects.AsNoTracking() - .Where(p => projectIdsToFetch.Contains(p.Id) && p.TenantId == tenantId) - .ToListAsync(); - }); - - // Task to get team sizes for the MISSING projects - var teamSizesTask = Task.Run(async () => - { - using var context = _dbContextFactory.CreateDbContext(); - return await context.ProjectAllocations.AsNoTracking() - .Where(pa => pa.TenantId == tenantId && projectIdsToFetch.Contains(pa.ProjectId) && pa.IsActive) - .GroupBy(pa => pa.ProjectId) - .Select(g => new { ProjectId = g.Key, Count = g.Count() }) - .ToDictionaryAsync(x => x.ProjectId, x => x.Count); - }); - - // Task to get work summaries for the MISSING projects - var workSummariesTask = Task.Run(async () => - { - using var context = _dbContextFactory.CreateDbContext(); - return await context.WorkItems.AsNoTracking() - .Where(wi => wi.TenantId == tenantId && - wi.WorkArea != null && - wi.WorkArea.Floor != null && - wi.WorkArea.Floor.Building != null && - projectIdsToFetch.Contains(wi.WorkArea.Floor.Building.ProjectId)) - .GroupBy(wi => wi.WorkArea!.Floor!.Building!.ProjectId) - .Select(g => new { ProjectId = g.Key, PlannedWork = g.Sum(i => i.PlannedWork), CompletedWork = g.Sum(i => i.CompletedWork) }) - .ToDictionaryAsync(x => x.ProjectId); - }); - - // Await all parallel tasks to complete - await Task.WhenAll(projectsTask, teamSizesTask, workSummariesTask); - - var projects = await projectsTask; - var teamSizes = await teamSizesTask; - var workSummaries = await workSummariesTask; - - // Proactively update the cache with the items we just fetched. - _logger.LogInfo("Updating cache with {NewItemCount} newly fetched projects.", projects.Count); - await _cache.AddProjectDetailsList(projects); - - // This section would build the full ProjectMongoDB objects, similar to your AddProjectDetailsList method. - // For brevity, assuming you have a mapper or a builder for this. Here's a simplified representation: - var mongoDetailsList = new List(); - foreach (var project in projects) - { - // This is a placeholder for the full build logic from your other methods. - // In a real scenario, you would fetch all hierarchy levels (buildings, floors, etc.) - // for the `projectIdsToFetch` and build the complete MongoDB object. - var mongoDetail = _mapper.Map(project); - mongoDetail.Id = project.Id; - mongoDetail.TeamSize = teamSizes.GetValueOrDefault(project.Id, 0); - if (workSummaries.TryGetValue(project.Id, out var summary)) - { - mongoDetail.PlannedWork = summary.PlannedWork; - mongoDetail.CompletedWork = summary.CompletedWork; - } - mongoDetailsList.Add(mongoDetail); - } - - return mongoDetailsList; - } - - /// - /// Private helper to encapsulate the cache-first data retrieval logic. - /// - /// A ProjectDetailVM if found, otherwise null. - private async Task GetProjectDataAsync(Guid projectId) - { - // --- Cache First --- - _logger.LogDebug("Attempting to fetch project {ProjectId} from cache.", projectId); - var cachedProject = await _cache.GetProjectDetails(projectId); - if (cachedProject != null) - { - _logger.LogInfo("Cache HIT for project {ProjectId}.", projectId); - // Map from the cache model (e.g., ProjectMongoDB) to the response ViewModel. - return _mapper.Map(cachedProject); - } - - // --- Database Second (on Cache Miss) --- - _logger.LogInfo("Cache MISS for project {ProjectId}. Fetching from database.", projectId); - var dbProject = await _context.Projects - .AsNoTracking() // Use AsNoTracking for read-only queries. - .Where(p => p.Id == projectId && p.TenantId == tenantId) - .SingleOrDefaultAsync(); - - if (dbProject == null) - { - return null; // The project doesn't exist. - } - - // --- Proactively Update Cache --- - // The next request for this project will now be a cache hit. - try - { - // Map the DB entity to the cache model (e.g., ProjectMongoDB) before caching. - await _cache.AddProjectDetails(dbProject); - _logger.LogInfo("Updated cache with project {ProjectId}.", projectId); - } - catch (Exception ex) - { - _logger.LogWarning("Failed to update cache for project {ProjectId} : \n {Error}", projectId, ex.Message); - } - - // Map from the database entity to the response ViewModel. - return dbProject; - } - - // Helper method for background cache update - private async Task UpdateCacheInBackground(Project project) - { - try - { - // This logic can be more complex, but the idea is to update or add. - if (!await _cache.UpdateProjectDetailsOnly(project)) - { - await _cache.AddProjectDetails(project); - } - _logger.LogInfo("Background cache update succeeded for project {ProjectId}.", project.Id); - } - catch (Exception ex) - { - _logger.LogError("Background cache update failed for project {ProjectId} \n {Error}", project.Id, ex.Message); - } - } - - // Helper method for background notification - private async Task SendNotificationInBackground(object notification, Guid projectId) - { - try - { - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - _logger.LogInfo("Background SignalR notification sent for project {ProjectId}.", projectId); - } - catch (Exception ex) - { - _logger.LogError("Background SignalR notification failed for project {ProjectId} \n {Error}", projectId, ex.Message); - } - } - - #endregion } } \ No newline at end of file diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs index 18db7ff..b811056 100644 --- a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs @@ -39,6 +39,7 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); + CreateMap(); } } } diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 7fa2647..6553745 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -6,6 +6,7 @@ using Marco.Pms.Model.Utilities; 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.Middleware; using MarcoBMS.Services.Service; @@ -154,8 +155,13 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); // Scoped services (one instance per HTTP request) +#region Customs Services builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +#endregion + +#region Helpers builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -164,9 +170,13 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +#endregion + +#region Cache Services builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +#endregion // Singleton services (one instance for the app's lifetime) builder.Services.AddSingleton(); diff --git a/Marco.Pms.Services/Service/PermissionServices.cs b/Marco.Pms.Services/Service/PermissionServices.cs index f20a768..9758a5f 100644 --- a/Marco.Pms.Services/Service/PermissionServices.cs +++ b/Marco.Pms.Services/Service/PermissionServices.cs @@ -37,7 +37,7 @@ namespace Marco.Pms.Services.Service if (projectIds == null) { - var hasPermission = await HasPermission(employeeId, PermissionsMaster.ManageProject); + var hasPermission = await HasPermission(PermissionsMaster.ManageProject, employeeId); if (hasPermission) { var projects = await _context.Projects.Where(c => c.TenantId == LoggedInEmployee.TenantId).ToListAsync(); @@ -45,12 +45,12 @@ namespace Marco.Pms.Services.Service } else { - var allocation = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive == true).ToListAsync(); - if (allocation.Any()) + var allocation = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive).ToListAsync(); + if (!allocation.Any()) { - projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); + return false; } - return false; + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); } await _cache.AddProjects(LoggedInEmployee.Id, projectIds); } diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs new file mode 100644 index 0000000..3280558 --- /dev/null +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -0,0 +1,691 @@ +using AutoMapper; +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.Activities; +using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Entitlements; +using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.Projects; +using Marco.Pms.Model.Utilities; +using Marco.Pms.Model.ViewModels.Projects; +using Marco.Pms.Services.Helpers; +using Marco.Pms.Services.Service.ServiceInterfaces; +using MarcoBMS.Services.Helpers; +using MarcoBMS.Services.Service; +using Microsoft.EntityFrameworkCore; + +namespace Marco.Pms.Services.Service +{ + public class ProjectServices : IProjectServices + { + private readonly IDbContextFactory _dbContextFactory; + private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate + private readonly ILoggingService _logger; + private readonly ProjectsHelper _projectsHelper; + private readonly PermissionServices _permission; + private readonly CacheUpdateHelper _cache; + private readonly IMapper _mapper; + public ProjectServices( + IDbContextFactory dbContextFactory, + ApplicationDbContext context, + ILoggingService logger, + ProjectsHelper projectsHelper, + PermissionServices permission, + CacheUpdateHelper cache, + IMapper mapper) + { + _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _projectsHelper = projectsHelper ?? throw new ArgumentNullException(nameof(projectsHelper)); + _permission = permission ?? throw new ArgumentNullException(nameof(permission)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + #region =================================================================== Project Get APIs =================================================================== + + public async Task> GetAllProjectsBasicAsync(Guid tenantId, Employee loggedInEmployee) + { + try + { + // Step 1: Verify the current user + if (loggedInEmployee == null) + { + return ApiResponse.ErrorResponse("Unauthorized", "User could not be identified.", 401); + } + + _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); + + // Step 2: Get the list of project IDs the user has access to + List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + + if (accessibleProjectIds == null || !accessibleProjectIds.Any()) + { + _logger.LogInfo("No accessible projects found for EmployeeId {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.SuccessResponse(new List(), "0 records of project fetchd successfully", 200); + } + + // Step 3: Fetch project ViewModels using the optimized, cache-aware helper + var projectVMs = await GetProjectInfosByIdsAsync(accessibleProjectIds); + + // Step 4: Return the final list + _logger.LogInfo("Successfully returned {ProjectCount} projects for EmployeeId {EmployeeId}", projectVMs.Count, loggedInEmployee.Id); + return ApiResponse.SuccessResponse(projectVMs, $"{projectVMs.Count} records of project fetchd successfully", 200); + } + catch (Exception ex) + { + // --- Step 5: Graceful Error Handling --- + _logger.LogError("An unexpected error occurred in GetAllProjectsBasic for tenant {TenantId}. \n {Error}", tenantId, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); + } + } + + public async Task> GetAllProjectsAsync(Guid tenantId, Employee loggedInEmployee) + { + try + { + _logger.LogInfo("Starting GetAllProjects for TenantId: {TenantId}, User: {UserId}", tenantId, loggedInEmployee.Id); + + // --- Step 1: Get a list of project IDs the user can access --- + List projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + if (!projectIds.Any()) + { + _logger.LogInfo("User has no assigned projects. Returning empty list."); + return ApiResponse.SuccessResponse(new List(), "No projects found for the current user.", 200); + } + + // --- Step 2: Efficiently handle partial cache hits --- + _logger.LogInfo("Attempting to fetch details for {ProjectCount} projects from cache.", projectIds.Count); + + // Fetch what we can from the cache. + var cachedDetails = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var cachedDictionary = cachedDetails.ToDictionary(p => Guid.Parse(p.Id)); + + // Identify which projects are missing from the cache. + var missingIds = projectIds.Where(id => !cachedDictionary.ContainsKey(id)).ToList(); + + // Start building the response with the items we found in the cache. + var responseVms = _mapper.Map>(cachedDictionary.Values); + + if (missingIds.Any()) + { + // --- Step 3: Fetch ONLY the missing items from the database --- + _logger.LogInfo("Cache partial MISS. Found {CachedCount}, fetching {MissingCount} projects from DB.", + cachedDictionary.Count, missingIds.Count); + + // Call our dedicated data-fetching method for the missing IDs. + var newMongoDetails = await FetchAndBuildProjectDetails(missingIds, tenantId); + + if (newMongoDetails.Any()) + { + // Map the newly fetched items and add them to our response list. + responseVms.AddRange(newMongoDetails); + } + } + else + { + _logger.LogInfo("Cache HIT. All {ProjectCount} projects found in cache.", projectIds.Count); + } + + // --- Step 4: Return the combined result --- + _logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", responseVms.Count); + return ApiResponse.SuccessResponse(responseVms, "Projects retrieved successfully.", 200); + } + catch (Exception ex) + { + // --- Step 5: Graceful Error Handling --- + _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. \n {Error}", tenantId, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); + } + } + + public async Task> GetProjectAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + try + { + // --- Step 1: Run independent operations in PARALLEL --- + // We can check permissions and fetch data at the same time to reduce latency. + var permissionTask = _permission.HasProjectPermission(loggedInEmployee, id); + + // This helper method encapsulates the "cache-first, then database" logic. + var projectDataTask = GetProjectDataAsync(id, tenantId); + + // Await both tasks to complete. + await Task.WhenAll(permissionTask, projectDataTask); + + var hasPermission = await permissionTask; + var projectVm = await projectDataTask; + + // --- Step 2: Process results sequentially --- + + // 2a. Check for permission first. Forbid() is the idiomatic way to return 403. + if (!hasPermission) + { + _logger.LogWarning("Access denied for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to access this project.", 403); + } + + // 2b. Check if the project was found (either in cache or DB). + if (projectVm == null) + { + _logger.LogInfo("Project with ID {ProjectId} not found.", id); + return ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404); + } + + // 2c. Success. Return the consistent ViewModel. + _logger.LogInfo("Successfully retrieved project {ProjectId}.", id); + return ApiResponse.SuccessResponse(projectVm, "Project retrieved successfully.", 200); + } + catch (Exception ex) + { + _logger.LogError("An unexpected error occurred while getting project {ProjectId} : \n {Error}", id, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); + } + } + + public async Task> GetProjectDetailsAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + try + { + _logger.LogInfo("Details requested by EmployeeId: {EmployeeId} for ProjectId: {ProjectId}", loggedInEmployee.Id, id); + + // Step 1: Check global view project permission + var hasViewProjectPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); + if (!hasViewProjectPermission) + { + _logger.LogWarning("ViewProjects permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Access denied", "You don't have permission to view projects", 403); + } + + // Step 2: Check permission for this specific project + var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, id); + if (!hasProjectPermission) + { + _logger.LogWarning("Project-specific access denied. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403); + } + + // Step 3: Fetch project with status + var projectDetails = await _cache.GetProjectDetails(id); + ProjectVM? projectVM = null; + if (projectDetails == null) + { + var project = await _context.Projects + .Include(c => c.ProjectStatus) + .FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id); + + projectVM = _mapper.Map(project); + + if (project != null) + { + await _cache.AddProjectDetails(project); + } + } + else + { + projectVM = _mapper.Map(projectDetails); + if (projectVM.ProjectStatus != null) + { + projectVM.ProjectStatus.TenantId = tenantId; + } + } + + if (projectVM == null) + { + _logger.LogWarning("Project not found. ProjectId: {ProjectId}", id); + return ApiResponse.ErrorResponse("Project not found", "Project not found", 404); + } + + // Step 4: Return result + + _logger.LogInfo("Project details fetched successfully. ProjectId: {ProjectId}", id); + return ApiResponse.SuccessResponse(projectVM, "Project details fetched successfully", 200); + } + catch (Exception ex) + { + // --- Step 5: Graceful Error Handling --- + _logger.LogError("An unexpected error occurred in Get Project Details for project {ProjectId} for tenant {TenantId}. \n {Error}", id, tenantId, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); + } + } + + public async Task> GetProjectDetailsOldAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + var project = await _context.Projects + .Where(c => c.TenantId == tenantId && c.Id == id) + .Include(c => c.ProjectStatus) + .SingleOrDefaultAsync(); + + if (project == null) + { + return ApiResponse.ErrorResponse("Project not found", "Project not found", 404); + + } + else + { + ProjectDetailsVM vm = await GetProjectViewModel(id, project); + + OldProjectVM projectVM = new OldProjectVM(); + if (vm.project != null) + { + projectVM.Id = vm.project.Id; + projectVM.Name = vm.project.Name; + projectVM.ShortName = vm.project.ShortName; + projectVM.ProjectAddress = vm.project.ProjectAddress; + projectVM.ContactPerson = vm.project.ContactPerson; + projectVM.StartDate = vm.project.StartDate; + projectVM.EndDate = vm.project.EndDate; + projectVM.ProjectStatusId = vm.project.ProjectStatusId; + } + projectVM.Buildings = new List(); + if (vm.buildings != null) + { + foreach (Building build in vm.buildings) + { + BuildingVM buildVM = new BuildingVM() { Id = build.Id, Description = build.Description, Name = build.Name }; + buildVM.Floors = new List(); + if (vm.floors != null) + { + foreach (Floor floorDto in vm.floors.Where(c => c.BuildingId == build.Id).ToList()) + { + FloorsVM floorVM = new FloorsVM() { FloorName = floorDto.FloorName, Id = floorDto.Id }; + floorVM.WorkAreas = new List(); + + if (vm.workAreas != null) + { + foreach (WorkArea workAreaDto in vm.workAreas.Where(c => c.FloorId == floorVM.Id).ToList()) + { + WorkAreaVM workAreaVM = new WorkAreaVM() { Id = workAreaDto.Id, AreaName = workAreaDto.AreaName, WorkItems = new List() }; + + if (vm.workItems != null) + { + foreach (WorkItem workItemDto in vm.workItems.Where(c => c.WorkAreaId == workAreaDto.Id).ToList()) + { + WorkItemVM workItemVM = new WorkItemVM() { WorkItemId = workItemDto.Id, WorkItem = workItemDto }; + + workItemVM.WorkItem.WorkArea = new WorkArea(); + + if (workItemVM.WorkItem.ActivityMaster != null) + { + workItemVM.WorkItem.ActivityMaster.Tenant = new Tenant(); + } + workItemVM.WorkItem.Tenant = new Tenant(); + + double todaysAssigned = 0; + if (vm.Tasks != null) + { + var tasks = vm.Tasks.Where(t => t.WorkItemId == workItemDto.Id).ToList(); + foreach (TaskAllocation task in tasks) + { + todaysAssigned += task.PlannedTask; + } + } + workItemVM.TodaysAssigned = todaysAssigned; + + workAreaVM.WorkItems.Add(workItemVM); + } + } + + floorVM.WorkAreas.Add(workAreaVM); + } + } + + buildVM.Floors.Add(floorVM); + } + } + projectVM.Buildings.Add(buildVM); + } + } + return ApiResponse.SuccessResponse(projectVM, "Success.", 200); + } + } + + #endregion + + #region =================================================================== Project Manage APIs =================================================================== + + public async Task> CreateProjectAsync(CreateProjectDto projectDto, Guid tenantId, Employee loggedInEmployee) + { + // 1. Prepare data without I/O + var loggedInUserId = loggedInEmployee.Id; + var project = _mapper.Map(projectDto); + project.TenantId = tenantId; + + // 2. Store it to database + try + { + _context.Projects.Add(project); + await _context.SaveChangesAsync(); + } + catch (Exception ex) + { + // Log the detailed exception + _logger.LogError("Failed to create project in database. Rolling back transaction. \n {Error}", ex.Message); + // Return a server error as the primary operation failed + return ApiResponse.ErrorResponse("An error occurred while saving the project.", ex.Message, 500); + } + + // 3. Perform non-critical side-effects (caching, notifications) concurrently + try + { + // These operations do not depend on each other, so they can run in parallel. + Task cacheAddDetailsTask = _cache.AddProjectDetails(project); + Task cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(PermissionsMaster.ManageProject); + + // Await all side-effect tasks to complete in parallel + await Task.WhenAll(cacheAddDetailsTask, cacheClearListTask); + } + catch (Exception ex) + { + // The project was created successfully, but a side-effect failed. + // Log this as a warning, as the primary operation succeeded. Don't return an error to the user. + _logger.LogWarning("Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. \n {Error}", project.Id, ex.Message); + } + + // 4. Return a success response to the user as soon as the critical data is saved. + return ApiResponse.SuccessResponse(_mapper.Map(project), "Project created successfully.", 200); + } + + /// + /// Updates an existing project's details. + /// This endpoint is secure, handles concurrency, and performs non-essential tasks in the background. + /// + /// The ID of the project to update. + /// The data to update the project with. + /// An ApiResponse confirming the update or an appropriate error. + public async Task> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee) + { + try + { + // --- Step 1: Fetch the Existing Entity from the Database --- + // This is crucial to avoid the data loss bug. We only want to modify an existing record. + var existingProject = await _context.Projects + .Where(p => p.Id == id && p.TenantId == tenantId) + .SingleOrDefaultAsync(); + + // 1a. Existence Check + if (existingProject == null) + { + _logger.LogWarning("Attempt to update non-existent project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404); + } + + // 1b. Security Check + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} attempting to update project {ProjectId}.", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to modify this project.", 403); + } + + // --- Step 2: Apply Changes and Save --- + // Map the changes from the DTO onto the entity we just fetched from the database. + // This only modifies the properties defined in the mapping, preventing data loss. + _mapper.Map(updateProjectDto, existingProject); + + // Mark the entity as modified (if your mapping doesn't do it automatically). + _context.Entry(existingProject).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.LogWarning("Concurrency conflict while updating project {ProjectId} \n {Error}", id, ex.Message); + return ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409); + } + + // --- Step 4: Perform Side-Effects in the Background (Fire and Forget) --- + // The core database operation is done. Now, we perform non-blocking cache and notification updates. + _ = Task.Run(async () => + { + // Create a DTO of the updated project to pass to background tasks. + var projectDto = _mapper.Map(existingProject); + + // 4a. Update Cache + await UpdateCacheInBackground(existingProject); + + }); + + // --- Step 5: Return Success Response Immediately --- + // The client gets a fast response without waiting for caching or SignalR. + return ApiResponse.SuccessResponse(_mapper.Map(existingProject), "Project updated successfully.", 200); + } + catch (Exception ex) + { + // --- Step 6: Graceful Error Handling for Unexpected Errors --- + _logger.LogError("An unexpected error occurred while updating project {ProjectId} \n {Error}", id, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); + } + } + + #endregion + + #region =================================================================== Helper Functions =================================================================== + + /// + /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. + /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the + /// database (as Project), updates the cache, and returns a unified list of ViewModels. + /// + /// The list of project IDs to retrieve. + /// A list of ProjectInfoVMs. + private async Task> GetProjectInfosByIdsAsync(List projectIds) + { + // --- Step 1: Fetch from Cache --- + // The cache returns a list of MongoDB documents for the projects it found. + var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var finalViewModels = _mapper.Map>(cachedMongoDocs); + + _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); + + // --- Step 2: Identify Missing Projects --- + // If we found everything in the cache, we can return early. + if (finalViewModels.Count == projectIds.Count) + { + return finalViewModels; + } + + var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id + var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); + + // --- Step 3: Fetch Missing from Database --- + if (missingIds.Any()) + { + _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); + + var projectsFromDb = await _context.Projects + .Where(p => missingIds.Contains(p.Id)) + .AsNoTracking() // Use AsNoTracking for read-only query performance + .ToListAsync(); + + if (projectsFromDb.Any()) + { + // Map the newly fetched projects (from SQL) to their ViewModel + var vmsFromDb = _mapper.Map>(projectsFromDb); + finalViewModels.AddRange(vmsFromDb); + + // --- Step 4: Update Cache with Missing Items in a new scope --- + _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); + await _cache.AddProjectDetailsList(projectsFromDb); + } + } + + return finalViewModels; + } + + private async Task GetProjectViewModel(Guid? id, Project project) + { + ProjectDetailsVM vm = new ProjectDetailsVM(); + + // List buildings = _unitOfWork.Building.GetAll(c => c.ProjectId == id).ToList(); + List buildings = await _context.Buildings.Where(c => c.ProjectId == id).ToListAsync(); + List idList = buildings.Select(o => o.Id).ToList(); + // List floors = _unitOfWork.Floor.GetAll(c => idList.Contains(c.Id)).ToList(); + List floors = await _context.Floor.Where(c => idList.Contains(c.BuildingId)).ToListAsync(); + idList = floors.Select(o => o.Id).ToList(); + //List workAreas = _unitOfWork.WorkArea.GetAll(c => idList.Contains(c.Id), includeProperties: "WorkItems,WorkItems.ActivityMaster").ToList(); + + List workAreas = await _context.WorkAreas.Where(c => idList.Contains(c.FloorId)).ToListAsync(); + + idList = workAreas.Select(o => o.Id).ToList(); + List workItems = await _context.WorkItems.Include(c => c.WorkCategoryMaster).Where(c => idList.Contains(c.WorkAreaId)).Include(c => c.ActivityMaster).ToListAsync(); + // List workItems = _unitOfWork.WorkItem.GetAll(c => idList.Contains(c.WorkAreaId), includeProperties: "ActivityMaster").ToList(); + idList = workItems.Select(t => t.Id).ToList(); + List tasks = await _context.TaskAllocations.Where(t => idList.Contains(t.WorkItemId) && t.AssignmentDate.Date == DateTime.UtcNow.Date).ToListAsync(); + vm.project = project; + vm.buildings = buildings; + vm.floors = floors; + vm.workAreas = workAreas; + vm.workItems = workItems; + vm.Tasks = tasks; + return vm; + } + + /// + /// Fetches project details from the database for a given list of project IDs and assembles them into MongoDB models. + /// This method encapsulates the optimized, parallel database queries. + /// + /// The list of project IDs to fetch. + /// The current tenant ID for filtering. + /// A list of fully populated ProjectMongoDB objects. + private async Task> FetchAndBuildProjectDetails(List projectIdsToFetch, Guid tenantId) + { + // Task to get base project details for the MISSING projects + var projectsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Projects.AsNoTracking() + .Where(p => projectIdsToFetch.Contains(p.Id) && p.TenantId == tenantId) + .ToListAsync(); + }); + + // Task to get team sizes for the MISSING projects + var teamSizesTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.ProjectAllocations.AsNoTracking() + .Where(pa => pa.TenantId == tenantId && projectIdsToFetch.Contains(pa.ProjectId) && pa.IsActive) + .GroupBy(pa => pa.ProjectId) + .Select(g => new { ProjectId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ProjectId, x => x.Count); + }); + + // Task to get work summaries for the MISSING projects + var workSummariesTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.WorkItems.AsNoTracking() + .Where(wi => wi.TenantId == tenantId && + wi.WorkArea != null && + wi.WorkArea.Floor != null && + wi.WorkArea.Floor.Building != null && + projectIdsToFetch.Contains(wi.WorkArea.Floor.Building.ProjectId)) + .GroupBy(wi => wi.WorkArea!.Floor!.Building!.ProjectId) + .Select(g => new { ProjectId = g.Key, PlannedWork = g.Sum(i => i.PlannedWork), CompletedWork = g.Sum(i => i.CompletedWork) }) + .ToDictionaryAsync(x => x.ProjectId); + }); + + // Await all parallel tasks to complete + await Task.WhenAll(projectsTask, teamSizesTask, workSummariesTask); + + var projects = await projectsTask; + var teamSizes = await teamSizesTask; + var workSummaries = await workSummariesTask; + + // Proactively update the cache with the items we just fetched. + _logger.LogInfo("Updating cache with {NewItemCount} newly fetched projects.", projects.Count); + await _cache.AddProjectDetailsList(projects); + + // This section would build the full ProjectMongoDB objects, similar to your AddProjectDetailsList method. + // For brevity, assuming you have a mapper or a builder for this. Here's a simplified representation: + var mongoDetailsList = new List(); + foreach (var project in projects) + { + // This is a placeholder for the full build logic from your other methods. + // In a real scenario, you would fetch all hierarchy levels (buildings, floors, etc.) + // for the `projectIdsToFetch` and build the complete MongoDB object. + var mongoDetail = _mapper.Map(project); + mongoDetail.Id = project.Id; + mongoDetail.TeamSize = teamSizes.GetValueOrDefault(project.Id, 0); + if (workSummaries.TryGetValue(project.Id, out var summary)) + { + mongoDetail.PlannedWork = summary.PlannedWork; + mongoDetail.CompletedWork = summary.CompletedWork; + } + mongoDetailsList.Add(mongoDetail); + } + + return mongoDetailsList; + } + + /// + /// Private helper to encapsulate the cache-first data retrieval logic. + /// + /// A ProjectDetailVM if found, otherwise null. + private async Task GetProjectDataAsync(Guid projectId, Guid tenantId) + { + // --- Cache First --- + _logger.LogDebug("Attempting to fetch project {ProjectId} from cache.", projectId); + var cachedProject = await _cache.GetProjectDetails(projectId); + if (cachedProject != null) + { + _logger.LogInfo("Cache HIT for project {ProjectId}.", projectId); + // Map from the cache model (e.g., ProjectMongoDB) to the response ViewModel. + return _mapper.Map(cachedProject); + } + + // --- Database Second (on Cache Miss) --- + _logger.LogInfo("Cache MISS for project {ProjectId}. Fetching from database.", projectId); + var dbProject = await _context.Projects + .AsNoTracking() // Use AsNoTracking for read-only queries. + .Where(p => p.Id == projectId && p.TenantId == tenantId) + .SingleOrDefaultAsync(); + + if (dbProject == null) + { + return null; // The project doesn't exist. + } + + // --- Proactively Update Cache --- + // The next request for this project will now be a cache hit. + try + { + // Map the DB entity to the cache model (e.g., ProjectMongoDB) before caching. + await _cache.AddProjectDetails(dbProject); + _logger.LogInfo("Updated cache with project {ProjectId}.", projectId); + } + catch (Exception ex) + { + _logger.LogWarning("Failed to update cache for project {ProjectId} : \n {Error}", projectId, ex.Message); + } + + // Map from the database entity to the response ViewModel. + return dbProject; + } + + // Helper method for background cache update + private async Task UpdateCacheInBackground(Project project) + { + try + { + // This logic can be more complex, but the idea is to update or add. + if (!await _cache.UpdateProjectDetailsOnly(project)) + { + await _cache.AddProjectDetails(project); + } + _logger.LogInfo("Background cache update succeeded for project {ProjectId}.", project.Id); + } + catch (Exception ex) + { + _logger.LogError("Background cache update failed for project {ProjectId} \n {Error}", project.Id, ex.Message); + } + } + + #endregion + } +} diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs new file mode 100644 index 0000000..a23eba0 --- /dev/null +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -0,0 +1,17 @@ +using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Utilities; + +namespace Marco.Pms.Services.Service.ServiceInterfaces +{ + public interface IProjectServices + { + Task> GetAllProjectsBasicAsync(Guid tenantId, Employee loggedInEmployee); + Task> GetAllProjectsAsync(Guid tenantId, Employee loggedInEmployee); + Task> GetProjectAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectDetailsAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectDetailsOldAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + Task> CreateProjectAsync(CreateProjectDto projectDto, Guid tenantId, Employee loggedInEmployee); + Task> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee); + } +} -- 2.43.0 From 73aa1d618150e1a48512cd975205e6b5abfddc6b Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 12:44:38 +0530 Subject: [PATCH 08/50] adde functionality to delete workItems from cache --- .../Controllers/AttendanceController.cs | 30 +-- .../Controllers/AuthController.cs | 34 +-- .../Controllers/DashboardController.cs | 10 +- .../Controllers/DirectoryController.cs | 4 +- .../Controllers/EmployeeController.cs | 4 +- .../Controllers/ForumController.cs | 30 +-- .../Controllers/MasterController.cs | 48 ++-- .../Controllers/ProjectController.cs | 129 ++++------ .../Controllers/ReportController.cs | 16 +- .../Helpers/CacheUpdateHelper.cs | 8 +- Marco.Pms.Services/Helpers/DirectoryHelper.cs | 18 +- Marco.Pms.Services/Helpers/EmployeeHelper.cs | 6 +- Marco.Pms.Services/Helpers/MasterHelper.cs | 10 +- Marco.Pms.Services/Helpers/ReportHelper.cs | 10 +- Marco.Pms.Services/Helpers/RolesHelper.cs | 4 +- ...ectMappingProfile.cs => MappingProfile.cs} | 12 +- Marco.Pms.Services/Program.cs | 1 + Marco.Pms.Services/Service/ILoggingService.cs | 2 +- Marco.Pms.Services/Service/LoggingServices.cs | 6 +- Marco.Pms.Services/Service/ProjectServices.cs | 227 +++++++++++++++++- .../Service/RefreshTokenService.cs | 14 +- Marco.Pms.Services/Service/S3UploadService.cs | 14 +- .../ServiceInterfaces/IProjectServices.cs | 2 + .../ServiceInterfaces/ISignalRService.cs | 7 + Marco.Pms.Services/Service/SignalRService.cs | 29 +++ 25 files changed, 444 insertions(+), 231 deletions(-) rename Marco.Pms.Services/MappingProfiles/{ProjectMappingProfile.cs => MappingProfile.cs} (75%) create mode 100644 Marco.Pms.Services/Service/ServiceInterfaces/ISignalRService.cs create mode 100644 Marco.Pms.Services/Service/SignalRService.cs diff --git a/Marco.Pms.Services/Controllers/AttendanceController.cs b/Marco.Pms.Services/Controllers/AttendanceController.cs index 4c2f2c1..1a5e4e7 100644 --- a/Marco.Pms.Services/Controllers/AttendanceController.cs +++ b/Marco.Pms.Services/Controllers/AttendanceController.cs @@ -90,18 +90,18 @@ namespace MarcoBMS.Services.Controllers if (dateFrom != null && DateTime.TryParse(dateFrom, out fromDate) == false) { - _logger.LogError("User sent Invalid from Date while featching attendance logs"); + _logger.LogWarning("User sent Invalid from Date while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (dateTo != null && DateTime.TryParse(dateTo, out toDate) == false) { - _logger.LogError("User sent Invalid to Date while featching attendance logs"); + _logger.LogWarning("User sent Invalid to Date while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (employeeId == Guid.Empty) { - _logger.LogError("The employee Id sent by user is empty"); + _logger.LogWarning("The employee Id sent by user is empty"); return BadRequest(ApiResponse.ErrorResponse("Employee ID is required and must not be Empty.", "Employee ID is required and must not be empty.", 400)); } List attendances = await _context.Attendes.Where(c => c.EmployeeID == employeeId && c.TenantId == TenantId && c.AttendanceDate.Date >= fromDate && c.AttendanceDate.Date <= toDate).ToListAsync(); @@ -161,18 +161,18 @@ namespace MarcoBMS.Services.Controllers if (dateFrom != null && DateTime.TryParse(dateFrom, out fromDate) == false) { - _logger.LogError("User sent Invalid fromDate while featching attendance logs"); + _logger.LogWarning("User sent Invalid fromDate while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (dateTo != null && DateTime.TryParse(dateTo, out toDate) == false) { - _logger.LogError("User sent Invalid toDate while featching attendance logs"); + _logger.LogWarning("User sent Invalid toDate while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (projectId == Guid.Empty) { - _logger.LogError("The project Id sent by user is less than or equal to zero"); + _logger.LogWarning("The project Id sent by user is less than or equal to zero"); return BadRequest(ApiResponse.ErrorResponse("Project ID is required and must be greater than zero.", "Project ID is required and must be greater than zero.", 400)); } @@ -276,13 +276,13 @@ namespace MarcoBMS.Services.Controllers if (date != null && DateTime.TryParse(date, out forDate) == false) { - _logger.LogError("User sent Invalid Date while featching attendance logs"); + _logger.LogWarning("User sent Invalid Date while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (projectId == Guid.Empty) { - _logger.LogError("The project Id sent by user is less than or equal to zero"); + _logger.LogWarning("The project Id sent by user is less than or equal to zero"); return BadRequest(ApiResponse.ErrorResponse("Project ID is required and must be greater than zero.", "Project ID is required and must be greater than zero.", 400)); } @@ -425,7 +425,7 @@ namespace MarcoBMS.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance \n {Error}", string.Join(",", errors)); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -439,14 +439,14 @@ namespace MarcoBMS.Services.Controllers if (recordAttendanceDot.MarkTime == null) { - _logger.LogError("User sent Invalid Mark Time while marking attendance"); + _logger.LogWarning("User sent Invalid Mark Time while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid Mark Time", "Invalid Mark Time", 400)); } DateTime finalDateTime = GetDateFromTimeStamp(recordAttendanceDot.Date, recordAttendanceDot.MarkTime); if (recordAttendanceDot.Comment == null) { - _logger.LogError("User sent Invalid comment while marking attendance"); + _logger.LogWarning("User sent Invalid comment while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid Comment", "Invalid Comment", 400)); } @@ -480,7 +480,7 @@ namespace MarcoBMS.Services.Controllers } else { - _logger.LogError("Employee {EmployeeId} sent regularization request but it check-out time is earlier than check-out"); + _logger.LogWarning("Employee {EmployeeId} sent regularization request but it check-out time is earlier than check-out"); return BadRequest(ApiResponse.ErrorResponse("Check-out time must be later than check-in time", "Check-out time must be later than check-in time", 400)); } // do nothing @@ -585,7 +585,7 @@ namespace MarcoBMS.Services.Controllers catch (Exception ex) { await transaction.RollbackAsync(); // Rollback on failure - _logger.LogError("{Error} while marking attendance", ex.Message); + _logger.LogError(ex, "An Error occured while marking attendance"); var response = new { message = ex.Message, @@ -604,7 +604,7 @@ namespace MarcoBMS.Services.Controllers if (!ModelState.IsValid) { var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); - _logger.LogError("Invalid attendance model received."); + _logger.LogWarning("Invalid attendance model received. \n {Error}", string.Join(",", errors)); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -780,7 +780,7 @@ namespace MarcoBMS.Services.Controllers catch (Exception ex) { await transaction.RollbackAsync(); - _logger.LogError("Error while recording attendance : {Error}", ex.Message); + _logger.LogError(ex, "Error while recording attendance"); return BadRequest(ApiResponse.ErrorResponse("Something went wrong", ex.Message, 500)); } } diff --git a/Marco.Pms.Services/Controllers/AuthController.cs b/Marco.Pms.Services/Controllers/AuthController.cs index 1b45eb7..429a38b 100644 --- a/Marco.Pms.Services/Controllers/AuthController.cs +++ b/Marco.Pms.Services/Controllers/AuthController.cs @@ -1,8 +1,4 @@ -using System.Net; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Authentication; using Marco.Pms.Model.Dtos.Authentication; using Marco.Pms.Model.Dtos.Util; @@ -15,6 +11,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; namespace MarcoBMS.Services.Controllers { @@ -110,7 +110,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Unexpected error during login : {Error}", ex.Message); + _logger.LogError(ex, "Unexpected error during login"); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error", ex.Message, 500)); } } @@ -270,7 +270,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Unexpected error occurred while verifying MPIN : {Error}", ex.Message); + _logger.LogError(ex, "Unexpected error occurred while verifying MPIN"); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error", ex.Message, 500)); } } @@ -307,7 +307,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Unexpected error during logout : {Error}", ex.Message); + _logger.LogError(ex, "Unexpected error during logout"); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error occurred", ex.Message, 500)); } } @@ -351,7 +351,7 @@ namespace MarcoBMS.Services.Controllers if (string.IsNullOrWhiteSpace(user.UserName)) { - _logger.LogError("Username missing for user ID: {UserId}", user.Id); + _logger.LogWarning("Username missing for user ID: {UserId}", user.Id); return NotFound(ApiResponse.ErrorResponse("Username not found.", "Username not found.", 404)); } @@ -370,7 +370,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred during token refresh. : {Error}", ex.Message); + _logger.LogError(ex, "An unexpected error occurred during token refresh."); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error occurred.", ex.Message, 500)); } } @@ -406,7 +406,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Error while sending password reset email to: {Error}", ex.Message); + _logger.LogError(ex, "Error while sending password reset email to"); return StatusCode(500, ApiResponse.ErrorResponse("Error sending password reset email.", ex.Message, 500)); } } @@ -480,7 +480,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Error while sending reset password success email to user: {Error}", ex.Message); + _logger.LogError(ex, "Error while sending reset password success email to user"); // Continue, do not fail because of email issue } @@ -547,7 +547,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred while sending OTP to {Email} : {Error}", generateOTP.Email ?? "", ex.Message); + _logger.LogError(ex, "An unexpected error occurred while sending OTP to {Email}", generateOTP.Email ?? ""); return StatusCode(500, ApiResponse.ErrorResponse("An unexpected error occurred.", ex.Message, 500)); } } @@ -638,7 +638,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred during OTP login for email {Email} : {Error}", verifyOTP.Email ?? string.Empty, ex.Message); + _logger.LogError(ex, "An unexpected error occurred during OTP login for email {Email}", verifyOTP.Email ?? string.Empty); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error", ex.Message, 500)); } } @@ -719,7 +719,7 @@ namespace MarcoBMS.Services.Controllers if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToList(); - _logger.LogError("Password reset failed for user {Email}. Errors: {Errors}", changePassword.Email, string.Join("; ", errors)); + _logger.LogWarning("Password reset failed for user {Email}. Errors: {Errors}", changePassword.Email, string.Join("; ", errors)); return BadRequest(ApiResponse.ErrorResponse("Failed to change password", errors, 400)); } @@ -732,7 +732,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception exp) { - _logger.LogError("An unexpected error occurred while changing password : {Error}", exp.Message); + _logger.LogError(exp, "An unexpected error occurred while changing password"); return StatusCode(500, ApiResponse.ErrorResponse("An unexpected error occurred.", exp.Message, 500)); } } @@ -752,7 +752,7 @@ namespace MarcoBMS.Services.Controllers // Validate employee and MPIN input if (requestEmployee == null || string.IsNullOrWhiteSpace(generateMPINDto.MPIN) || generateMPINDto.MPIN.Length != 6 || !generateMPINDto.MPIN.All(char.IsDigit)) { - _logger.LogError("Employee {EmployeeId} provided invalid information to generate MPIN", loggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} provided invalid information to generate MPIN", loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse("Provided invalid information", "Provided invalid information", 400)); } diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index bdb965c..934725a 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -221,7 +221,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Number of pending regularization and pending check-out are fetched successfully for employee {EmployeeId}", LoggedInEmployee.Id); return Ok(ApiResponse.SuccessResponse(response, "Pending regularization and pending check-out are fetched successfully", 200)); } - _logger.LogError("No attendance entry was found for employee {EmployeeId}", LoggedInEmployee.Id); + _logger.LogWarning("No attendance entry was found for employee {EmployeeId}", LoggedInEmployee.Id); return NotFound(ApiResponse.ErrorResponse("No attendance entry was found for this employee", "No attendance entry was found for this employee", 404)); } @@ -235,14 +235,14 @@ namespace Marco.Pms.Services.Controllers List? projectProgressionVMs = new List(); if (date != null && DateTime.TryParse(date, out currentDate) == false) { - _logger.LogError($"user send invalid date"); + _logger.LogWarning($"user send invalid date"); return BadRequest(ApiResponse.ErrorResponse("Invalid date.", "Invalid date.", 400)); } Project? project = await _context.Projects.FirstOrDefaultAsync(p => p.Id == projectId); if (project == null) { - _logger.LogError("Employee {EmployeeId} was attempted to get project attendance for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); + _logger.LogWarning("Employee {EmployeeId} was attempted to get project attendance for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); } List? projectAllocation = await _context.ProjectAllocations.Where(p => p.ProjectId == projectId && p.IsActive && p.TenantId == tenantId).ToListAsync(); @@ -288,14 +288,14 @@ namespace Marco.Pms.Services.Controllers DateTime currentDate = DateTime.UtcNow; if (date != null && DateTime.TryParse(date, out currentDate) == false) { - _logger.LogError($"user send invalid date"); + _logger.LogWarning($"user send invalid date"); return BadRequest(ApiResponse.ErrorResponse("Invalid date.", "Invalid date.", 400)); } Project? project = await _context.Projects.FirstOrDefaultAsync(p => p.Id == projectId); if (project == null) { - _logger.LogError("Employee {EmployeeId} was attempted to get activities performed for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); + _logger.LogWarning("Employee {EmployeeId} was attempted to get activities performed for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); } diff --git a/Marco.Pms.Services/Controllers/DirectoryController.cs b/Marco.Pms.Services/Controllers/DirectoryController.cs index 4a0e41e..9eb06e0 100644 --- a/Marco.Pms.Services/Controllers/DirectoryController.cs +++ b/Marco.Pms.Services/Controllers/DirectoryController.cs @@ -77,7 +77,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _directoryHelper.CreateContact(createContact); @@ -256,7 +256,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _directoryHelper.CreateBucket(bucketDto); diff --git a/Marco.Pms.Services/Controllers/EmployeeController.cs b/Marco.Pms.Services/Controllers/EmployeeController.cs index 2f0ca5e..c9e19fa 100644 --- a/Marco.Pms.Services/Controllers/EmployeeController.cs +++ b/Marco.Pms.Services/Controllers/EmployeeController.cs @@ -382,7 +382,7 @@ namespace MarcoBMS.Services.Controllers Employee? existingEmployee = await _context.Employees.FirstOrDefaultAsync(e => e.Id == model.Id.Value); if (existingEmployee == null) { - _logger.LogError("User tries to update employee {EmployeeId} but not found in database", model.Id); + _logger.LogWarning("User tries to update employee {EmployeeId} but not found in database", model.Id); return NotFound(ApiResponse.ErrorResponse("Employee not found", "Employee not found", 404)); } byte[]? imageBytes = null; @@ -495,7 +495,7 @@ namespace MarcoBMS.Services.Controllers } else { - _logger.LogError("Employee with ID {EmploueeId} not found in database", id); + _logger.LogWarning("Employee with ID {EmploueeId} not found in database", id); } return Ok(ApiResponse.SuccessResponse(new { }, "Employee Suspended successfully", 200)); } diff --git a/Marco.Pms.Services/Controllers/ForumController.cs b/Marco.Pms.Services/Controllers/ForumController.cs index 769c08a..fb6d0e7 100644 --- a/Marco.Pms.Services/Controllers/ForumController.cs +++ b/Marco.Pms.Services/Controllers/ForumController.cs @@ -44,7 +44,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } Guid tenantId = _userHelper.GetTenantId(); @@ -66,7 +66,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -160,7 +160,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } Guid tenantId = _userHelper.GetTenantId(); @@ -197,7 +197,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -336,7 +336,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket {TicketId} updated", updateTicketDto.Id); return Ok(ApiResponse.SuccessResponse(ticketVM, "Ticket Updated Successfully", 200)); } - _logger.LogError("Ticket {TicketId} not Found in database", updateTicketDto.Id); + _logger.LogWarning("Ticket {TicketId} not Found in database", updateTicketDto.Id); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -349,7 +349,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -364,7 +364,7 @@ namespace Marco.Pms.Services.Controllers if (ticket == null) { - _logger.LogError("Ticket {TicketId} not Found in database", addCommentDto.TicketId); + _logger.LogWarning("Ticket {TicketId} not Found in database", addCommentDto.TicketId); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -379,7 +379,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -437,7 +437,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -451,7 +451,7 @@ namespace Marco.Pms.Services.Controllers if (ticket == null) { - _logger.LogError("Ticket {TicketId} not Found in database", updateCommentDto.TicketId); + _logger.LogWarning("Ticket {TicketId} not Found in database", updateCommentDto.TicketId); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -474,7 +474,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -552,7 +552,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -568,7 +568,7 @@ namespace Marco.Pms.Services.Controllers if (tickets == null || tickets.Count > 0) { - _logger.LogError("Tickets not Found in database"); + _logger.LogWarning("Tickets not Found in database"); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -578,12 +578,12 @@ namespace Marco.Pms.Services.Controllers { if (string.IsNullOrEmpty(forumAttachmentDto.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } if (forumAttachmentDto.TicketId == null) { - _logger.LogError("ticket ID is missing"); + _logger.LogWarning("ticket ID is missing"); return BadRequest(ApiResponse.ErrorResponse("ticket ID is missing", "ticket ID is missing", 400)); } var ticket = tickets.FirstOrDefault(t => t.Id == forumAttachmentDto.TicketId); diff --git a/Marco.Pms.Services/Controllers/MasterController.cs b/Marco.Pms.Services/Controllers/MasterController.cs index ebd8998..9000cdf 100644 --- a/Marco.Pms.Services/Controllers/MasterController.cs +++ b/Marco.Pms.Services/Controllers/MasterController.cs @@ -168,7 +168,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("activity updated successfully from tenant {tenantId}", tenantId); return Ok(ApiResponse.SuccessResponse(activityVM, "activity updated successfully", 200)); } - _logger.LogError("Activity {ActivityId} not found", id); + _logger.LogWarning("Activity {ActivityId} not found", id); return NotFound(ApiResponse.ErrorResponse("Activity not found", "Activity not found", 404)); } @@ -230,7 +230,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Status master {TicketStatusId} added successfully from tenant {tenantId}", statusMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(statusVM, "Ticket Status master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("Sent Empty payload", "Sent Empty payload", 400)); } @@ -251,10 +251,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Status master {TicketStatusId} updated successfully from tenant {tenantId}", statusMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(statusVM, "Ticket Status master updated successfully", 200)); } - _logger.LogError("Ticket Status master {TicketStatusId} not found in database", statusMasterDto.Id != null ? statusMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket Status master {TicketStatusId} not found in database", statusMasterDto.Id != null ? statusMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket Status master not found", "Ticket Status master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("Sent Empty payload", "Sent Empty payload", 400)); } @@ -281,7 +281,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Status {TickeStatusId} not found in database", id); + _logger.LogWarning("Ticket Status {TickeStatusId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket Status not found", "Ticket Status not found", 404)); } } @@ -318,7 +318,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket type master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -339,10 +339,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Type master {TicketTypeId} updated successfully from tenant {tenantId}", typeMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket type master updated successfully", 200)); } - _logger.LogError("Ticket type master {TicketTypeId} not found in database", typeMasterDto.Id != null ? typeMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket type master {TicketTypeId} not found in database", typeMasterDto.Id != null ? typeMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket type master not found", "Ticket type master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -369,7 +369,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Type {TickeTypeId} not found in database", id); + _logger.LogWarning("Ticket Type {TickeTypeId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket Type not found", "Ticket Type not found", 404)); } } @@ -407,7 +407,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket Priority master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } [HttpPost("ticket-priorities/edit/{id}")] @@ -427,10 +427,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Priority master {TicketPriorityId} updated successfully from tenant {tenantId}", typeMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket Priority master updated successfully", 200)); } - _logger.LogError("Ticket Priority master {TicketPriorityId} not found in database", priorityMasterDto.Id != null ? priorityMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket Priority master {TicketPriorityId} not found in database", priorityMasterDto.Id != null ? priorityMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket Priority master not found", "Ticket Priority master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -457,7 +457,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Priority {TickePriorityId} not found in database", id); + _logger.LogWarning("Ticket Priority {TickePriorityId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket Priority not found", "Ticket Priority not found", 404)); } } @@ -494,7 +494,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket tag master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -515,10 +515,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Tag master {TicketTypeId} updated successfully from tenant {tenantId}", tagMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket tag master updated successfully", 200)); } - _logger.LogError("Ticket tag master {TicketTypeId} not found in database", tagMasterDto.Id != null ? tagMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket tag master {TicketTypeId} not found in database", tagMasterDto.Id != null ? tagMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket tag master not found", "Ticket tag master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -545,7 +545,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Tag {TickeTagId} not found in database", id); + _logger.LogWarning("Ticket Tag {TickeTagId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket tag not found", "Ticket tag not found", 404)); } } @@ -609,7 +609,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(workCategoryMasterVM, "Work category master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -624,7 +624,7 @@ namespace Marco.Pms.Services.Controllers { if (workCategory.IsSystem) { - _logger.LogError("User tries to update system-defined work category"); + _logger.LogWarning("User tries to update system-defined work category"); return BadRequest(ApiResponse.ErrorResponse("Cannot update system-defined work", "Cannot update system-defined work", 400)); } workCategory = workCategoryMasterDto.ToWorkCategoryMasterFromWorkCategoryMasterDto(tenantId); @@ -635,10 +635,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Work category master {WorkCategoryId} updated successfully from tenant {tenantId}", workCategory.Id, tenantId); return Ok(ApiResponse.SuccessResponse(workCategoryMasterVM, "Work category master updated successfully", 200)); } - _logger.LogError("Work category master {WorkCategoryId} not found in database", workCategoryMasterDto.Id ?? Guid.Empty); + _logger.LogWarning("Work category master {WorkCategoryId} not found in database", workCategoryMasterDto.Id ?? Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Work category master not found", "Work category master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -666,7 +666,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Work category {WorkCategoryId} not found in database", id); + _logger.LogWarning("Work category {WorkCategoryId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Work category not found", "Work category not found", 404)); } } @@ -689,7 +689,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _masterHelper.CreateWorkStatus(createWorkStatusDto); @@ -803,7 +803,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _masterHelper.CreateContactTag(contactTagDto); diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index e7d257f..236e0cb 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -6,19 +6,18 @@ using Marco.Pms.Model.Mapper; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; -using Marco.Pms.Model.ViewModels.Employee; using Marco.Pms.Model.ViewModels.Projects; 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 MongoDB.Driver; +using Project = Marco.Pms.Model.Projects.Project; namespace MarcoBMS.Services.Controllers { @@ -31,14 +30,20 @@ namespace MarcoBMS.Services.Controllers private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly ILoggingService _logger; - private readonly IHubContext _signalR; + private readonly ISignalRService _signalR; private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; private readonly Guid tenantId; - public ProjectController(ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, - IHubContext signalR, CacheUpdateHelper cache, PermissionServices permission, IProjectServices projectServices) + public ProjectController( + ApplicationDbContext context, + UserHelper userHelper, + ILoggingService logger, + ISignalRService signalR, + CacheUpdateHelper cache, + PermissionServices permission, + IProjectServices projectServices) { _context = context; _userHelper = userHelper; @@ -174,7 +179,7 @@ namespace MarcoBMS.Services.Controllers if (response.Success) { var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Create_Project", Response = response.Data }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); } return StatusCode(response.StatusCode, response); } @@ -204,7 +209,7 @@ namespace MarcoBMS.Services.Controllers if (response.Success) { var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Update_Project", Response = response.Data }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); } return StatusCode(response.StatusCode, response); } @@ -213,90 +218,38 @@ namespace MarcoBMS.Services.Controllers #region =================================================================== Project Allocation APIs =================================================================== - [HttpGet] - [Route("employees/get/{projectid?}/{includeInactive?}")] - public async Task GetEmployeeByProjectID(Guid? projectid, bool includeInactive = false) + [HttpGet("employees/get/{projectid?}/{includeInactive?}")] + public async Task GetEmployeeByProjectId(Guid? projectId, bool includeInactive = false) { + // --- Step 1: Input Validation --- if (!ModelState.IsValid) { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get employee list by ProjectId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - if (projectid != null) - { - // Fetch assigned project - List result = new List(); - - if ((bool)includeInactive) - { - - result = await (from rpm in _context.Employees.Include(c => c.JobRole) - join fp in _context.ProjectAllocations.Where(c => c.TenantId == tenantId && c.ProjectId == projectid) - on rpm.Id equals fp.EmployeeId - select rpm).ToListAsync(); - } - else - { - result = await (from rpm in _context.Employees.Include(c => c.JobRole) - join fp in _context.ProjectAllocations.Where(c => c.TenantId == tenantId && c.ProjectId == projectid && c.IsActive) - on rpm.Id equals fp.EmployeeId - select rpm).ToListAsync(); - } - - List resultVM = new List(); - foreach (Employee employee in result) - { - EmployeeVM vm = employee.ToEmployeeVMFromEmployee(); - resultVM.Add(vm); - } - - return Ok(ApiResponse.SuccessResponse(resultVM, "Success.", 200)); - } - else - { - return NotFound(ApiResponse.ErrorResponse("Invalid Input Parameter", 404)); - } - - + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetEmployeeByProjectIdAsync(projectId, includeInactive, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } - [HttpGet] - [Route("allocation/{projectId}")] + [HttpGet("allocation/{projectId}")] public async Task GetProjectAllocation(Guid? projectId) { + // --- Step 1: Input Validation --- if (!ModelState.IsValid) { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get employee list by ProjectId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - var employees = await _context.ProjectAllocations - .Where(c => c.TenantId == tenantId && c.ProjectId == projectId && c.Employee != null) - .Include(e => e.Employee) - .Select(e => new - { - ID = e.Id, - EmployeeId = e.EmployeeId, - ProjectId = e.ProjectId, - AllocationDate = e.AllocationDate, - ReAllocationDate = e.ReAllocationDate, - FirstName = e.Employee != null ? e.Employee.FirstName : string.Empty, - LastName = e.Employee != null ? e.Employee.LastName : string.Empty, - MiddleName = e.Employee != null ? e.Employee.MiddleName : string.Empty, - IsActive = e.IsActive, - JobRoleId = (e.JobRoleId != null ? e.JobRoleId : e.Employee != null ? e.Employee.JobRoleId : null) - }).ToListAsync(); - - return Ok(ApiResponse.SuccessResponse(employees, "Success.", 200)); + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectAllocationAsync(projectId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpPost("allocation")] @@ -375,7 +328,7 @@ namespace MarcoBMS.Services.Controllers } var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); } @@ -494,7 +447,7 @@ namespace MarcoBMS.Services.Controllers await _cache.ClearAllProjectIds(employeeId); var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); } @@ -799,7 +752,7 @@ namespace MarcoBMS.Services.Controllers var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = message }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); return Ok(ApiResponse.SuccessResponse(responseList, responseMessage, 200)); } @@ -826,9 +779,15 @@ namespace MarcoBMS.Services.Controllers workAreaIds.Add(task.WorkAreaId); + var projectId = floor?.Building?.ProjectId; var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = $"Task Deleted in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}, in Area: {task.WorkArea?.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}" }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); + await _cache.DeleteWorkItemByIdAsync(task.Id); + if (projectId != null) + { + await _cache.DeleteProjectByIdAsync(projectId.Value); + } } else { @@ -847,7 +806,7 @@ namespace MarcoBMS.Services.Controllers } else { - _logger.LogError("Task with ID {WorkItemId} not found ID database", id); + _logger.LogWarning("Task with ID {WorkItemId} not found ID database", id); } return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); } @@ -973,7 +932,7 @@ namespace MarcoBMS.Services.Controllers message = $"{message} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); return Ok(ApiResponse.SuccessResponse(responseData, responseMessage, 200)); } return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400)); diff --git a/Marco.Pms.Services/Controllers/ReportController.cs b/Marco.Pms.Services/Controllers/ReportController.cs index 717a273..87382d7 100644 --- a/Marco.Pms.Services/Controllers/ReportController.cs +++ b/Marco.Pms.Services/Controllers/ReportController.cs @@ -106,7 +106,7 @@ namespace Marco.Pms.Services.Controllers } catch (Exception ex) { - _logger.LogError("Database Error: Failed to check existence of MailListId '{MailListId}' for TenantId: {TenantId}. : {Error}", mailDetailsDto.MailListId, tenantId, ex.Message); + _logger.LogError(ex, "Database Error: Failed to check existence of MailListId '{MailListId}' for TenantId: {TenantId}.", mailDetailsDto.MailListId, tenantId); return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while validating mail template.", 500)); } @@ -143,13 +143,13 @@ namespace Marco.Pms.Services.Controllers } catch (DbUpdateException dbEx) { - _logger.LogError("Database Error: Failed to save new mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}. : {Error}", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId, dbEx.Message); + _logger.LogError(dbEx, "Database Error: Failed to save new mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}.", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId); // Check for specific constraint violations if applicable (e.g., duplicate recipient for a project) return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while saving the mail details.", 500)); } catch (Exception ex) { - _logger.LogError("Unexpected Error: An unhandled exception occurred while adding mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}. : {Error}", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId, ex.Message); + _logger.LogError(ex, "Unexpected Error: An unhandled exception occurred while adding mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}.", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId); return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500)); } } @@ -234,7 +234,7 @@ namespace Marco.Pms.Services.Controllers } catch (Exception ex) { - _logger.LogError("Database Error: Failed to check for existing mail template with title '{Title}' for TenantId: {TenantId}.: {Error}", mailTemplateDto.Title, tenantId, ex.Message); + _logger.LogError(ex, "Database Error: Failed to check for existing mail template with title '{Title}' for TenantId: {TenantId}.", mailTemplateDto.Title, tenantId); return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while checking for existing templates.", 500)); } @@ -270,12 +270,12 @@ namespace Marco.Pms.Services.Controllers } catch (DbUpdateException dbEx) { - _logger.LogError("Database Error: Failed to save new mail template '{Title}' for TenantId: {TenantId}. : {Error}", mailTemplateDto.Title, tenantId, dbEx.Message); + _logger.LogError(dbEx, "Database Error: Failed to save new mail template '{Title}' for TenantId: {TenantId}. : {Error}", mailTemplateDto.Title, tenantId); return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while saving the mail template.", 500)); } catch (Exception ex) { - _logger.LogError("Unexpected Error: An unhandled exception occurred while adding mail template '{Title}' for TenantId: {TenantId}. : {Error}", mailTemplateDto.Title, tenantId, ex.Message); + _logger.LogError(ex, "Unexpected Error: An unhandled exception occurred while adding mail template '{Title}' for TenantId: {TenantId}.", mailTemplateDto.Title, tenantId); return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500)); } } @@ -350,7 +350,7 @@ namespace Marco.Pms.Services.Controllers { // 3. OPTIMIZATION: Make the process resilient. // If one task fails unexpectedly, log it and continue with others. - _logger.LogError("Failed to send report for project {ProjectId} : {Error}", mailGroup.ProjectId, ex.Message); + _logger.LogError(ex, "Failed to send report for project {ProjectId}", mailGroup.ProjectId); Interlocked.Increment(ref failureCount); } } @@ -527,7 +527,7 @@ namespace Marco.Pms.Services.Controllers catch (Exception ex) { // It's good practice to log any unexpected errors within a concurrent task. - _logger.LogError("Failed to process project report for ProjectId {ProjectId} : {Error}", group.ProjectId, ex.Message); + _logger.LogError(ex, "Failed to process project report for ProjectId {ProjectId}", group.ProjectId); } } }); diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 5bae90f..aca439b 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -118,8 +118,8 @@ namespace Marco.Pms.Services.Helpers projectDetails.ProjectStatus = new StatusMasterMongoDB { - Id = status?.Id.ToString(), - Status = status?.Status + Id = status!.Id.ToString(), + Status = status.Status }; // Use fast in-memory lookups instead of .Where() in loops. @@ -797,7 +797,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occured while fetching project report mail bodys: {Error}", ex.Message); + _logger.LogError(ex, "Error occured while fetching project report mail bodys"); return null; } } @@ -809,7 +809,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occured while adding project report mail bodys: {Error}", ex.Message); + _logger.LogError(ex, "Error occured while adding project report mail bodys"); } } } diff --git a/Marco.Pms.Services/Helpers/DirectoryHelper.cs b/Marco.Pms.Services/Helpers/DirectoryHelper.cs index 37f58cf..f8e1b07 100644 --- a/Marco.Pms.Services/Helpers/DirectoryHelper.cs +++ b/Marco.Pms.Services/Helpers/DirectoryHelper.cs @@ -52,7 +52,7 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to access a contacts, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to access a contacts, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -202,7 +202,7 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to access a contacts with in bucket {BucketId}, but do not have permission", LoggedInEmployee.Id, id); + _logger.LogWarning("Employee {EmployeeId} attemped to access a contacts with in bucket {BucketId}, but do not have permission", LoggedInEmployee.Id, id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -490,7 +490,7 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to update a contact, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to update a contact, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -1169,7 +1169,7 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to access a buckets list, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to access a buckets list, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -1204,7 +1204,7 @@ namespace Marco.Pms.Services.Helpers var demo = !permissionIds.Contains(PermissionsMaster.DirectoryUser); if (!permissionIds.Contains(PermissionsMaster.DirectoryAdmin) && !permissionIds.Contains(PermissionsMaster.DirectoryAdmin) && !permissionIds.Contains(PermissionsMaster.DirectoryUser)) { - _logger.LogError("Employee {EmployeeId} attemped to create a bucket, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to create a bucket, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -1276,7 +1276,7 @@ namespace Marco.Pms.Services.Helpers } if (accessableBucket == null) { - _logger.LogError("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); + _logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 401); } @@ -1342,7 +1342,7 @@ namespace Marco.Pms.Services.Helpers } if (accessableBucket == null) { - _logger.LogError("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); + _logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 401); } var employeeIds = await _context.Employees.Where(e => e.TenantId == tenantId && e.IsActive).Select(e => e.Id).ToListAsync(); @@ -1396,7 +1396,7 @@ namespace Marco.Pms.Services.Helpers } if (removededEmployee > 0) { - _logger.LogError("Employee {EmployeeId} removed {conut} number of employees from bucket {BucketId}", LoggedInEmployee.Id, removededEmployee, bucketId); + _logger.LogWarning("Employee {EmployeeId} removed {conut} number of employees from bucket {BucketId}", LoggedInEmployee.Id, removededEmployee, bucketId); } return ApiResponse.SuccessResponse(bucketVM, "Details updated successfully", 200); } @@ -1443,7 +1443,7 @@ namespace Marco.Pms.Services.Helpers } if (accessableBucket == null) { - _logger.LogError("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); + _logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 401); } diff --git a/Marco.Pms.Services/Helpers/EmployeeHelper.cs b/Marco.Pms.Services/Helpers/EmployeeHelper.cs index 03184e5..17e5746 100644 --- a/Marco.Pms.Services/Helpers/EmployeeHelper.cs +++ b/Marco.Pms.Services/Helpers/EmployeeHelper.cs @@ -33,7 +33,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occured while fetching employee by application user ID {ApplicationUserId}", ApplicationUserID); return new Employee(); } } @@ -66,7 +66,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occoured while filtering employees by string {SearchString} or project {ProjectId}", searchString, ProjectId ?? Guid.Empty); return new List(); } } @@ -102,7 +102,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occured while featching list of employee by project ID {ProjectId}", ProjectId ?? Guid.Empty); return new List(); } } diff --git a/Marco.Pms.Services/Helpers/MasterHelper.cs b/Marco.Pms.Services/Helpers/MasterHelper.cs index f994639..83bc007 100644 --- a/Marco.Pms.Services/Helpers/MasterHelper.cs +++ b/Marco.Pms.Services/Helpers/MasterHelper.cs @@ -218,7 +218,7 @@ namespace Marco.Pms.Services.Helpers _logger.LogInfo("Contact tag master {ConatctTagId} updated successfully by employee {EmployeeId}", contactTagVm.Id, LoggedInEmployee.Id); return ApiResponse.SuccessResponse(contactTagVm, "Contact Tag master updated successfully", 200); } - _logger.LogError("Contact Tag master {ContactTagId} not found in database", id); + _logger.LogWarning("Contact Tag master {ContactTagId} not found in database", id); return ApiResponse.ErrorResponse("Contact Tag master not found", "Contact tag master not found", 404); } _logger.LogWarning("Employee with ID {LoggedInEmployeeId} sended empty payload", LoggedInEmployee.Id); @@ -294,7 +294,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while fetching work status list : {Error}", ex.Message); + _logger.LogWarning("Error occurred while fetching work status list : {Error}", ex.Message); return ApiResponse.ErrorResponse("An error occurred", "Unable to fetch work status list", 500); } } @@ -343,7 +343,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while creating work status : {Error}", ex.Message); + _logger.LogWarning("Error occurred while creating work status : {Error}", ex.Message); return ApiResponse.ErrorResponse("An error occurred", "Unable to create work status", 500); } } @@ -403,7 +403,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while updating work status ID: {Id} : {Error}", id, ex.Message); + _logger.LogError(ex, "Error occurred while updating work status ID: {Id}", id); return ApiResponse.ErrorResponse("An error occurred", "Unable to update the work status at this time", 500); } } @@ -458,7 +458,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while deleting WorkStatus Id: {Id} : {Error}", id, ex.Message); + _logger.LogError(ex, "Error occurred while deleting WorkStatus Id: {Id}", id); return ApiResponse.ErrorResponse("An error occurred", "Unable to delete work status", 500); } } diff --git a/Marco.Pms.Services/Helpers/ReportHelper.cs b/Marco.Pms.Services/Helpers/ReportHelper.cs index 4ec0978..4ec9453 100644 --- a/Marco.Pms.Services/Helpers/ReportHelper.cs +++ b/Marco.Pms.Services/Helpers/ReportHelper.cs @@ -289,13 +289,13 @@ namespace Marco.Pms.Services.Helpers // --- Input Validation --- if (projectId == Guid.Empty) { - _logger.LogError("Validation Error: Provided empty project ID while fetching project report."); + _logger.LogWarning("Validation Error: Provided empty project ID while fetching project report."); return ApiResponse.ErrorResponse("Provided empty Project ID.", "Provided empty Project ID.", 400); } if (recipientEmails == null || !recipientEmails.Any()) { - _logger.LogError("Validation Error: No recipient emails provided for project ID {ProjectId}.", projectId); + _logger.LogWarning("Validation Error: No recipient emails provided for project ID {ProjectId}.", projectId); return ApiResponse.ErrorResponse("No recipient emails provided.", "No recipient emails provided.", 400); } @@ -316,7 +316,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Email Sending Error: Failed to send project statistics email for project ID {ProjectId}. : {Error}", projectId, ex.Message); + _logger.LogError(ex, "Email Sending Error: Failed to send project statistics email for project ID {ProjectId}.", projectId); return ApiResponse.ErrorResponse("Failed to send email.", "An error occurred while sending the email.", 500); } @@ -350,14 +350,14 @@ namespace Marco.Pms.Services.Helpers } catch (DbUpdateException dbEx) { - _logger.LogError("Database Error: Failed to save mail logs for project ID {ProjectId}. : {Error}", projectId, dbEx.Message); + _logger.LogError(dbEx, "Database Error: Failed to save mail logs for project ID {ProjectId}.", projectId); // Depending on your requirements, you might still return success here as the email was sent. // Or return an error indicating the logging failed. return ApiResponse.ErrorResponse("Email sent, but failed to log activity.", "Email sent, but an error occurred while logging.", 500); } catch (Exception ex) { - _logger.LogError("Unexpected Error: An unhandled exception occurred while processing project statistics for project ID {ProjectId}. : {Error}", projectId, ex.Message); + _logger.LogError(ex, "Unexpected Error: An unhandled exception occurred while processing project statistics for project ID {ProjectId}.", projectId); return ApiResponse.ErrorResponse("An unexpected error occurred.", "An unexpected error occurred.", 500); } } diff --git a/Marco.Pms.Services/Helpers/RolesHelper.cs b/Marco.Pms.Services/Helpers/RolesHelper.cs index cd73c0f..ef9f824 100644 --- a/Marco.Pms.Services/Helpers/RolesHelper.cs +++ b/Marco.Pms.Services/Helpers/RolesHelper.cs @@ -84,7 +84,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("An error occurred while fetching permissions for EmployeeId {EmployeeId} : {Error}", EmployeeId, ex.Message); + _logger.LogError(ex, "An error occurred while fetching permissions for EmployeeId {EmployeeId}", EmployeeId); return new List(); } } @@ -144,7 +144,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("An error occurred while fetching permissions for RoleId {RoleId}: {Error}", roleId, ex.Message); + _logger.LogError(ex, "An error occurred while fetching permissions for RoleId {RoleId}", roleId); // Return an empty list as a safe default to prevent downstream failures. return new List(); } diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs similarity index 75% rename from Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs rename to Marco.Pms.Services/MappingProfiles/MappingProfile.cs index b811056..7d627bc 100644 --- a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -1,16 +1,19 @@ using AutoMapper; using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Employees; using Marco.Pms.Model.Master; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; +using Marco.Pms.Model.ViewModels.Employee; using Marco.Pms.Model.ViewModels.Projects; namespace Marco.Pms.Services.MappingProfiles { - public class ProjectMappingProfile : Profile + public class MappingProfile : Profile { - public ProjectMappingProfile() + public MappingProfile() { + #region ======================================================= Projects ======================================================= // Your mappings CreateMap(); CreateMap(); @@ -40,6 +43,11 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); CreateMap(); + #endregion + + #region ======================================================= Projects ======================================================= + CreateMap(); + #endregion } } } diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 6553745..26d8eba 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -158,6 +158,7 @@ builder.Services.AddTransient(); #region Customs Services builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion diff --git a/Marco.Pms.Services/Service/ILoggingService.cs b/Marco.Pms.Services/Service/ILoggingService.cs index b835d0c..6d795cd 100644 --- a/Marco.Pms.Services/Service/ILoggingService.cs +++ b/Marco.Pms.Services/Service/ILoggingService.cs @@ -5,7 +5,7 @@ void LogInfo(string? message, params object[]? args); void LogDebug(string? message, params object[]? args); void LogWarning(string? message, params object[]? args); - void LogError(string? message, params object[]? args); + void LogError(Exception? ex, string? message, params object[]? args); } } diff --git a/Marco.Pms.Services/Service/LoggingServices.cs b/Marco.Pms.Services/Service/LoggingServices.cs index 5a016de..751f22c 100644 --- a/Marco.Pms.Services/Service/LoggingServices.cs +++ b/Marco.Pms.Services/Service/LoggingServices.cs @@ -11,16 +11,16 @@ namespace MarcoBMS.Services.Service _logger = logger; } - public void LogError(string? message, params object[]? args) + public void LogError(Exception? ex, string? message, params object[]? args) { using (LogContext.PushProperty("LogLevel", "Error")) if (args != null) { - _logger.LogError(message, args); + _logger.LogError(ex, message, args); } else { - _logger.LogError(message); + _logger.LogError(ex, message); } } diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 3280558..dcaf20e 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -1,4 +1,5 @@ using AutoMapper; +using AutoMapper.QueryableExtensions; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Activities; using Marco.Pms.Model.Dtos.Project; @@ -7,12 +8,15 @@ using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; +using Marco.Pms.Model.ViewModels.Employee; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; +using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; +using Project = Marco.Pms.Model.Projects.Project; namespace Marco.Pms.Services.Service { @@ -75,7 +79,7 @@ namespace Marco.Pms.Services.Service catch (Exception ex) { // --- Step 5: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in GetAllProjectsBasic for tenant {TenantId}. \n {Error}", tenantId, ex.Message); + _logger.LogError(ex, "An unexpected error occurred in GetAllProjectsBasic for tenant {TenantId}.", tenantId); return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); } } @@ -134,7 +138,7 @@ namespace Marco.Pms.Services.Service catch (Exception ex) { // --- Step 5: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. \n {Error}", tenantId, ex.Message); + _logger.LogError(ex, "An unexpected error occurred in GetAllProjects for tenant {TenantId}.", tenantId); return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); } } @@ -178,7 +182,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("An unexpected error occurred while getting project {ProjectId} : \n {Error}", id, ex.Message); + _logger.LogError(ex, "An unexpected error occurred while getting project {ProjectId}", id); return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); } } @@ -244,7 +248,7 @@ namespace Marco.Pms.Services.Service catch (Exception ex) { // --- Step 5: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in Get Project Details for project {ProjectId} for tenant {TenantId}. \n {Error}", id, tenantId, ex.Message); + _logger.LogError(ex, "An unexpected error occurred in Get Project Details for project {ProjectId} for tenant {TenantId}. ", id, tenantId); return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); } } @@ -360,7 +364,7 @@ namespace Marco.Pms.Services.Service catch (Exception ex) { // Log the detailed exception - _logger.LogError("Failed to create project in database. Rolling back transaction. \n {Error}", ex.Message); + _logger.LogError(ex, "Failed to create project in database. Rolling back transaction."); // Return a server error as the primary operation failed return ApiResponse.ErrorResponse("An error occurred while saving the project.", ex.Message, 500); } @@ -379,7 +383,7 @@ namespace Marco.Pms.Services.Service { // The project was created successfully, but a side-effect failed. // Log this as a warning, as the primary operation succeeded. Don't return an error to the user. - _logger.LogWarning("Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. \n {Error}", project.Id, ex.Message); + _logger.LogError(ex, "Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. ", project.Id); } // 4. Return a success response to the user as soon as the critical data is saved. @@ -435,7 +439,7 @@ namespace Marco.Pms.Services.Service { // --- Step 3: Handle Concurrency Conflicts --- // This happens if another user modified the project after we fetched it. - _logger.LogWarning("Concurrency conflict while updating project {ProjectId} \n {Error}", id, ex.Message); + _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); } @@ -458,13 +462,216 @@ namespace Marco.Pms.Services.Service catch (Exception ex) { // --- Step 6: Graceful Error Handling for Unexpected Errors --- - _logger.LogError("An unexpected error occurred while updating project {ProjectId} \n {Error}", id, ex.Message); + _logger.LogError(ex, "An unexpected error occurred while updating project {ProjectId} ", id); return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); } } #endregion + #region =================================================================== Project Allocation APIs =================================================================== + + public async Task> GetEmployeeByProjectID(Guid? projectid, bool includeInactive, Guid tenantId, Employee loggedInEmployee) + { + if (projectid == null) + { + return ApiResponse.ErrorResponse("Invalid Input Parameter", 404); + } + // Fetch assigned project + List result = new List(); + + var employeeQuery = _context.ProjectAllocations + .Include(pa => pa.Employee) + .Where(pa => pa.ProjectId == projectid && pa.TenantId == tenantId && pa.Employee != null); + + if (includeInactive) + { + + result = await employeeQuery.Select(pa => pa.Employee ?? new Employee()).ToListAsync(); + } + else + { + result = await employeeQuery + .Where(pa => pa.IsActive) + .Select(pa => pa.Employee ?? new Employee()).ToListAsync(); + } + + List resultVM = new List(); + foreach (Employee employee in result) + { + EmployeeVM vm = _mapper.Map(employee); + resultVM.Add(vm); + } + + return ApiResponse.SuccessResponse(resultVM, "Successfully fetched the list of employees for seleted project", 200); + } + + /// + /// Retrieves a list of employees for a specific project. + /// This method is optimized to perform all filtering and mapping on the database server. + /// + /// The ID of the project. + /// Whether to include employees from inactive allocations. + /// The ID of the current tenant. + /// The current authenticated employee (used for permission checks). + /// An ApiResponse containing a list of employees or an error. + public async Task> GetEmployeeByProjectIdAsync(Guid? projectId, bool includeInactive, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (projectId == null) + { + _logger.LogWarning("GetEmployeeByProjectID called with a null projectId."); + // 400 Bad Request is more appropriate for invalid input than 404 Not Found. + return ApiResponse.ErrorResponse("Project ID is required.", "Invalid Input Parameter", 400); + } + + _logger.LogInfo("Fetching employees for ProjectID: {ProjectId}, IncludeInactive: {IncludeInactive}", projectId, includeInactive); + + try + { + // --- CRITICAL: Security Check --- + // Before fetching data, you MUST verify the user has permission to see it. + // This is a placeholder for your actual permission logic. + var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); + var hasAllEmployeePermission = await _permission.HasPermission(PermissionsMaster.ViewAllEmployees, loggedInEmployee.Id); + var hasviewTeamPermission = await _permission.HasPermission(PermissionsMaster.ViewTeamMembers, loggedInEmployee.Id); + + if (!(hasProjectPermission && (hasAllEmployeePermission || hasviewTeamPermission))) + { + _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to view this project's team.", 403); + } + + // --- Step 2: Build a Single, Efficient IQueryable --- + // We start with the base query and conditionally add filters before executing it. + // This avoids code duplication and is highly performant. + var employeeQuery = _context.ProjectAllocations + .Where(pa => pa.ProjectId == projectId && pa.TenantId == tenantId); + + // Conditionally apply the filter for active allocations. + if (!includeInactive) + { + employeeQuery = employeeQuery.Where(pa => pa.IsActive); + } + + // --- Step 3: Project Directly to the ViewModel on the Database Server --- + // This is the most significant performance optimization. + // Instead of fetching full Employee entities, we select only the data needed for the EmployeeVM. + // AutoMapper's ProjectTo is perfect for this, as it translates the mapping configuration into an efficient SQL SELECT statement. + var resultVM = await employeeQuery + .Where(pa => pa.Employee != null) // Safety check for data integrity + .Select(pa => pa.Employee) // Navigate to the Employee entity + .ProjectTo(_mapper.ConfigurationProvider) // Let AutoMapper generate the SELECT + .ToListAsync(); + + _logger.LogInfo("Successfully fetched {EmployeeCount} employees for project {ProjectId}.", resultVM.Count, projectId); + + // Note: The original mapping loop is now completely gone, replaced by the single efficient query above. + + return ApiResponse.SuccessResponse(resultVM, "Successfully fetched the list of employees for the selected project.", 200); + } + catch (Exception ex) + { + // --- Step 4: Graceful Error Handling --- + _logger.LogError(ex, "An error occurred while fetching employees for project {ProjectId}. ", projectId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "Database Query Failed", 500); + } + } + + public async Task> GetProjectAllocation(Guid? projectId, Guid tenantId, Employee loggedInEmployee) + { + var employees = await _context.ProjectAllocations + .Where(c => c.TenantId == tenantId && c.ProjectId == projectId && c.Employee != null) + .Include(e => e.Employee) + .Select(e => new + { + ID = e.Id, + EmployeeId = e.EmployeeId, + ProjectId = e.ProjectId, + AllocationDate = e.AllocationDate, + ReAllocationDate = e.ReAllocationDate, + FirstName = e.Employee != null ? e.Employee.FirstName : string.Empty, + LastName = e.Employee != null ? e.Employee.LastName : string.Empty, + MiddleName = e.Employee != null ? e.Employee.MiddleName : string.Empty, + IsActive = e.IsActive, + JobRoleId = (e.JobRoleId != null ? e.JobRoleId : e.Employee != null ? e.Employee.JobRoleId : null) + }).ToListAsync(); + + return ApiResponse.SuccessResponse(employees, "Success.", 200); + } + + /// + /// Retrieves project allocation details for a specific project. + /// This method is optimized for performance and includes security checks. + /// + /// The ID of the project. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing allocation details or an appropriate error. + public async Task> GetProjectAllocationAsync(Guid? projectId, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (projectId == null) + { + _logger.LogWarning("GetProjectAllocation called with a null projectId."); + return ApiResponse.ErrorResponse("Project ID is required.", "Invalid Input Parameter", 400); + } + + _logger.LogInfo("Fetching allocations for ProjectID: {ProjectId} for user {UserId}", projectId, loggedInEmployee.Id); + + try + { + // --- Step 2: Security and Existence Checks --- + // Before fetching data, you MUST verify the user has permission to see it. + // This is a placeholder for your actual permission logic. + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to view this project's team.", 403); + } + + // --- Step 3: Execute a Single, Optimized Database Query --- + // This query projects directly to a new object on the database server, which is highly efficient. + var allocations = await _context.ProjectAllocations + // Filter down to the relevant records first. + .Where(pa => pa.ProjectId == projectId && pa.TenantId == tenantId && pa.Employee != null) + // Project directly to the final shape. This tells EF Core which columns to select. + // The redundant .Include() is removed as EF Core infers the JOIN from this Select. + .Select(pa => new + { + // Fields from ProjectAllocation + ID = pa.Id, + pa.EmployeeId, + pa.ProjectId, + pa.AllocationDate, + pa.ReAllocationDate, + pa.IsActive, + + // Fields from the joined Employee table (no null checks needed due to the 'Where' clause) + FirstName = pa.Employee!.FirstName, + LastName = pa.Employee.LastName, + MiddleName = pa.Employee.MiddleName, + + // Simplified JobRoleId logic: Use the allocation's role if it exists, otherwise fall back to the employee's default role. + JobRoleId = pa.JobRoleId ?? pa.Employee.JobRoleId + }) + .ToListAsync(); + + _logger.LogInfo("Successfully fetched {AllocationCount} allocations for project {ProjectId}.", allocations.Count, projectId); + + return ApiResponse.SuccessResponse(allocations, "Project allocations retrieved successfully.", 200); + } + catch (Exception ex) + { + // --- Step 4: Graceful Error Handling --- + // Log the full exception for debugging, but return a generic, safe error message. + _logger.LogError(ex, "An error occurred while fetching allocations for project {ProjectId}.", projectId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "Database query failed.", 500); + } + } + #endregion + #region =================================================================== Helper Functions =================================================================== /// @@ -661,7 +868,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogWarning("Failed to update cache for project {ProjectId} : \n {Error}", projectId, ex.Message); + _logger.LogError(ex, "Failed to update cache for project {ProjectId} : ", projectId); } // Map from the database entity to the response ViewModel. @@ -682,7 +889,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("Background cache update failed for project {ProjectId} \n {Error}", project.Id, ex.Message); + _logger.LogError(ex, "Background cache update failed for project {ProjectId} ", project.Id); } } diff --git a/Marco.Pms.Services/Service/RefreshTokenService.cs b/Marco.Pms.Services/Service/RefreshTokenService.cs index 231e27c..84ef3fd 100644 --- a/Marco.Pms.Services/Service/RefreshTokenService.cs +++ b/Marco.Pms.Services/Service/RefreshTokenService.cs @@ -1,11 +1,11 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; #nullable disable namespace MarcoBMS.Services.Service @@ -94,7 +94,7 @@ namespace MarcoBMS.Services.Service } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occured while creating new JWT token for user {UserId}", userId); throw; } } @@ -132,7 +132,7 @@ namespace MarcoBMS.Services.Service } catch (Exception ex) { - _logger.LogError("Error creating MPIN token for userId: {UserId}, tenantId: {TenantId}, error : {Error}", userId, tenantId, ex.Message); + _logger.LogError(ex, "Error creating MPIN token for userId: {UserId}, tenantId: {TenantId}", userId, tenantId); throw; } } @@ -218,7 +218,7 @@ namespace MarcoBMS.Services.Service catch (Exception ex) { // Token is invalid - _logger.LogError($"Token validation failed: {ex.Message}"); + _logger.LogError(ex, "Token validation failed"); return null; } } diff --git a/Marco.Pms.Services/Service/S3UploadService.cs b/Marco.Pms.Services/Service/S3UploadService.cs index c29cfdd..4ce7a4b 100644 --- a/Marco.Pms.Services/Service/S3UploadService.cs +++ b/Marco.Pms.Services/Service/S3UploadService.cs @@ -64,7 +64,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("{error} while uploading file to S3", ex.Message); + _logger.LogError(ex, "error occured while uploading file to S3"); } @@ -87,7 +87,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("{error} while requesting presigned url from Amazon S3", ex.Message); + _logger.LogError(ex, "error occured while requesting presigned url from Amazon S3", ex.Message); return string.Empty; } } @@ -107,7 +107,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("{error} while deleting from Amazon S3", ex.Message); + _logger.LogError(ex, "error ocured while deleting from Amazon S3"); return false; } } @@ -202,7 +202,7 @@ namespace Marco.Pms.Services.Service } else { - _logger.LogError("Warning: Could not find MimeType, Type, or ContentType property in Definition."); + _logger.LogWarning("Warning: Could not find MimeType, Type, or ContentType property in Definition."); return "application/octet-stream"; } } @@ -211,16 +211,16 @@ namespace Marco.Pms.Services.Service return "application/octet-stream"; // Default if type cannot be determined } } - catch (FormatException) + catch (FormatException fEx) { // Handle cases where the input string is not valid Base64 - _logger.LogError("Invalid Base64 string."); + _logger.LogError(fEx, "Invalid Base64 string."); return string.Empty; } catch (Exception ex) { // Handle other potential errors during decoding or inspection - _logger.LogError($"An error occurred: {ex.Message}"); + _logger.LogError(ex, "errors during decoding or inspection"); return string.Empty; } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index a23eba0..d0539b0 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -13,5 +13,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> GetProjectDetailsOldAsync(Guid id, Guid tenantId, Employee loggedInEmployee); Task> CreateProjectAsync(CreateProjectDto projectDto, Guid tenantId, Employee loggedInEmployee); Task> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee); + Task> GetEmployeeByProjectIdAsync(Guid? projectId, bool includeInactive, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectAllocationAsync(Guid? projectId, Guid tenantId, Employee loggedInEmployee); } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/ISignalRService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/ISignalRService.cs new file mode 100644 index 0000000..c37322b --- /dev/null +++ b/Marco.Pms.Services/Service/ServiceInterfaces/ISignalRService.cs @@ -0,0 +1,7 @@ +namespace Marco.Pms.Services.Service.ServiceInterfaces +{ + public interface ISignalRService + { + Task SendNotificationAsync(object notification); + } +} diff --git a/Marco.Pms.Services/Service/SignalRService.cs b/Marco.Pms.Services/Service/SignalRService.cs new file mode 100644 index 0000000..fecc9b0 --- /dev/null +++ b/Marco.Pms.Services/Service/SignalRService.cs @@ -0,0 +1,29 @@ +using Marco.Pms.Services.Hubs; +using Marco.Pms.Services.Service.ServiceInterfaces; +using MarcoBMS.Services.Service; +using Microsoft.AspNetCore.SignalR; + +namespace Marco.Pms.Services.Service +{ + public class SignalRService : ISignalRService + { + private readonly IHubContext _signalR; + private readonly ILoggingService _logger; + public SignalRService(IHubContext signalR, ILoggingService logger) + { + _signalR = signalR ?? throw new ArgumentNullException(nameof(signalR)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + public async Task SendNotificationAsync(object notification) + { + try + { + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception occured during sending notification through signalR"); + } + } + } +} -- 2.43.0 From 5369bbae297e424bf97e0f9faad274cf2c674727 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 13:09:27 +0530 Subject: [PATCH 09/50] Solved the issue of project is not updating properly --- Marco.Pms.CacheHelper/ProjectCache.cs | 4 +- .../Controllers/ProjectController.cs | 101 +++-------- Marco.Pms.Services/Service/ProjectServices.cs | 158 ++++++++++-------- 3 files changed, 111 insertions(+), 152 deletions(-) diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index 183bbc4..c7d7e84 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -42,8 +42,8 @@ namespace Marco.Pms.CacheHelper Builders.Update.Set(r => r.ShortName, project.ShortName), Builders.Update.Set(r => r.ProjectStatus, new StatusMasterMongoDB { - Id = projectStatus?.Id.ToString(), - Status = projectStatus?.Status + Id = projectStatus.Id.ToString(), + Status = projectStatus.Status }), Builders.Update.Set(r => r.StartDate, project.StartDate), Builders.Update.Set(r => r.EndDate, project.EndDate), diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 236e0cb..0122003 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -252,89 +252,28 @@ namespace MarcoBMS.Services.Controllers return StatusCode(response.StatusCode, response); } - [HttpPost("allocation")] - public async Task ManageAllocation(List projectAllocationDot) - { - if (projectAllocationDot != null) - { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + //[HttpPost("allocation")] + //public async Task ManageAllocation(List projectAllocationDot) + //{ + // // --- Step 1: Input Validation --- + // if (!ModelState.IsValid) + // { + // var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + // _logger.LogWarning("Update project called with invalid model state for ID {ProjectId}. Errors: {Errors}", id, string.Join(", ", errors)); + // return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + // } - List? result = new List(); - List employeeIds = new List(); - List projectIds = new List(); + // // --- Step 2: Prepare data without I/O --- + // Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + // var response = await _projectServices.UpdateProjectAsync(id, updateProjectDto, tenantId, loggedInEmployee); + // if (response.Success) + // { + // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; + // await _signalR.SendNotificationAsync(notification); + // } + // return StatusCode(response.StatusCode, response); - foreach (var item in projectAllocationDot) - { - try - { - ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); - ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == projectAllocation.EmployeeId - && c.ProjectId == projectAllocation.ProjectId - && c.ReAllocationDate == null - && c.TenantId == tenantId).SingleOrDefaultAsync(); - - if (projectAllocationFromDb != null) - { - _context.ProjectAllocations.Attach(projectAllocationFromDb); - - if (item.Status) - { - projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; - projectAllocationFromDb.IsActive = true; - _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - } - else - { - projectAllocationFromDb.ReAllocationDate = DateTime.Now; - projectAllocationFromDb.IsActive = false; - _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - - employeeIds.Add(projectAllocation.EmployeeId); - projectIds.Add(projectAllocation.ProjectId); - } - await _context.SaveChangesAsync(); - var result1 = new - { - Id = projectAllocationFromDb.Id, - EmployeeId = projectAllocation.EmployeeId, - JobRoleId = projectAllocation.JobRoleId, - IsActive = projectAllocation.IsActive, - ProjectId = projectAllocation.ProjectId, - AllocationDate = projectAllocation.AllocationDate, - ReAllocationDate = projectAllocation.ReAllocationDate, - TenantId = projectAllocation.TenantId - }; - result.Add(result1); - } - else - { - projectAllocation.AllocationDate = DateTime.Now; - projectAllocation.IsActive = true; - _context.ProjectAllocations.Add(projectAllocation); - await _context.SaveChangesAsync(); - - employeeIds.Add(projectAllocation.EmployeeId); - projectIds.Add(projectAllocation.ProjectId); - } - await _cache.ClearAllProjectIds(item.EmpID); - - } - catch (Exception ex) - { - return Ok(ApiResponse.ErrorResponse(ex.Message, ex, 400)); - } - } - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; - - await _signalR.SendNotificationAsync(notification); - return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); - - } - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Work Item Details are not valid.", 400)); - - } + //} [HttpGet("assigned-projects/{employeeId}")] public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index dcaf20e..7717584 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -443,21 +443,16 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409); } - // --- Step 4: Perform Side-Effects in the Background (Fire and Forget) --- - // The core database operation is done. Now, we perform non-blocking cache and notification updates. - _ = Task.Run(async () => - { - // Create a DTO of the updated project to pass to background tasks. - var projectDto = _mapper.Map(existingProject); + // --- Step 4: Perform Side-Effects (Fire and Forget) --- + // Create a DTO of the updated project to pass to background tasks. + var projectDto = _mapper.Map(existingProject); - // 4a. Update Cache - await UpdateCacheInBackground(existingProject); - - }); + // 4a. Update Cache + await UpdateCacheInBackground(existingProject); // --- Step 5: Return Success Response Immediately --- // The client gets a fast response without waiting for caching or SignalR. - return ApiResponse.SuccessResponse(_mapper.Map(existingProject), "Project updated successfully.", 200); + return ApiResponse.SuccessResponse(projectDto, "Project updated successfully.", 200); } catch (Exception ex) { @@ -471,41 +466,6 @@ namespace Marco.Pms.Services.Service #region =================================================================== Project Allocation APIs =================================================================== - public async Task> GetEmployeeByProjectID(Guid? projectid, bool includeInactive, Guid tenantId, Employee loggedInEmployee) - { - if (projectid == null) - { - return ApiResponse.ErrorResponse("Invalid Input Parameter", 404); - } - // Fetch assigned project - List result = new List(); - - var employeeQuery = _context.ProjectAllocations - .Include(pa => pa.Employee) - .Where(pa => pa.ProjectId == projectid && pa.TenantId == tenantId && pa.Employee != null); - - if (includeInactive) - { - - result = await employeeQuery.Select(pa => pa.Employee ?? new Employee()).ToListAsync(); - } - else - { - result = await employeeQuery - .Where(pa => pa.IsActive) - .Select(pa => pa.Employee ?? new Employee()).ToListAsync(); - } - - List resultVM = new List(); - foreach (Employee employee in result) - { - EmployeeVM vm = _mapper.Map(employee); - resultVM.Add(vm); - } - - return ApiResponse.SuccessResponse(resultVM, "Successfully fetched the list of employees for seleted project", 200); - } - /// /// Retrieves a list of employees for a specific project. /// This method is optimized to perform all filtering and mapping on the database server. @@ -578,28 +538,6 @@ namespace Marco.Pms.Services.Service } } - public async Task> GetProjectAllocation(Guid? projectId, Guid tenantId, Employee loggedInEmployee) - { - var employees = await _context.ProjectAllocations - .Where(c => c.TenantId == tenantId && c.ProjectId == projectId && c.Employee != null) - .Include(e => e.Employee) - .Select(e => new - { - ID = e.Id, - EmployeeId = e.EmployeeId, - ProjectId = e.ProjectId, - AllocationDate = e.AllocationDate, - ReAllocationDate = e.ReAllocationDate, - FirstName = e.Employee != null ? e.Employee.FirstName : string.Empty, - LastName = e.Employee != null ? e.Employee.LastName : string.Empty, - MiddleName = e.Employee != null ? e.Employee.MiddleName : string.Empty, - IsActive = e.IsActive, - JobRoleId = (e.JobRoleId != null ? e.JobRoleId : e.Employee != null ? e.Employee.JobRoleId : null) - }).ToListAsync(); - - return ApiResponse.SuccessResponse(employees, "Success.", 200); - } - /// /// Retrieves project allocation details for a specific project. /// This method is optimized for performance and includes security checks. @@ -670,6 +608,87 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("An internal server error occurred.", "Database query failed.", 500); } } + + //public async Task> ManageAllocation(List projectAllocationDot, Guid tenantId, Employee loggedInEmployee) + //{ + // if (projectAllocationDot != null) + // { + // List? result = new List(); + // List employeeIds = new List(); + // List projectIds = new List(); + + // foreach (var item in projectAllocationDot) + // { + // try + // { + // //ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); + // ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); + // ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == projectAllocation.EmployeeId + // && c.ProjectId == projectAllocation.ProjectId + // && c.ReAllocationDate == null + // && c.TenantId == tenantId).SingleOrDefaultAsync(); + + // if (projectAllocationFromDb != null) + // { + // _context.ProjectAllocations.Attach(projectAllocationFromDb); + + // if (item.Status) + // { + // projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; + // projectAllocationFromDb.IsActive = true; + // _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; + // _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; + // } + // else + // { + // projectAllocationFromDb.ReAllocationDate = DateTime.Now; + // projectAllocationFromDb.IsActive = false; + // _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; + // _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; + + // employeeIds.Add(projectAllocation.EmployeeId); + // projectIds.Add(projectAllocation.ProjectId); + // } + // await _context.SaveChangesAsync(); + // var result1 = new + // { + // Id = projectAllocationFromDb.Id, + // EmployeeId = projectAllocation.EmployeeId, + // JobRoleId = projectAllocation.JobRoleId, + // IsActive = projectAllocation.IsActive, + // ProjectId = projectAllocation.ProjectId, + // AllocationDate = projectAllocation.AllocationDate, + // ReAllocationDate = projectAllocation.ReAllocationDate, + // TenantId = projectAllocation.TenantId + // }; + // result.Add(result1); + // } + // else + // { + // projectAllocation.AllocationDate = DateTime.Now; + // projectAllocation.IsActive = true; + // _context.ProjectAllocations.Add(projectAllocation); + // await _context.SaveChangesAsync(); + + // employeeIds.Add(projectAllocation.EmployeeId); + // projectIds.Add(projectAllocation.ProjectId); + // } + // await _cache.ClearAllProjectIds(item.EmpID); + + // } + // catch (Exception ex) + // { + // return ApiResponse.ErrorResponse(ex.Message, ex, 400); + // } + // } + + // return ApiResponse.SuccessResponse(result, "Data saved successfully", 200); + + // } + // return ApiResponse.ErrorResponse("Invalid details.", "Work Item Details are not valid.", 400); + + //} + #endregion #region =================================================================== Helper Functions =================================================================== @@ -881,7 +900,8 @@ namespace Marco.Pms.Services.Service try { // This logic can be more complex, but the idea is to update or add. - if (!await _cache.UpdateProjectDetailsOnly(project)) + var demo = await _cache.UpdateProjectDetailsOnly(project); + if (!demo) { await _cache.AddProjectDetails(project); } -- 2.43.0 From a64ce4fb0246e2f536580989a0e8b4b8444a53b0 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 14:34:26 +0530 Subject: [PATCH 10/50] Removed unused code from employee cache class --- Marco.Pms.CacheHelper/EmployeeCache.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Marco.Pms.CacheHelper/EmployeeCache.cs b/Marco.Pms.CacheHelper/EmployeeCache.cs index 4a668f0..2211393 100644 --- a/Marco.Pms.CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.CacheHelper/EmployeeCache.cs @@ -1,5 +1,4 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.MongoDBModels; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using MongoDB.Driver; @@ -8,13 +7,10 @@ namespace Marco.Pms.CacheHelper { public class EmployeeCache { - private readonly ApplicationDbContext _context; - //private readonly IMongoDatabase _mongoDB; private readonly IMongoCollection _collection; - public EmployeeCache(ApplicationDbContext context, IConfiguration configuration) + public EmployeeCache(IConfiguration configuration) { var connectionString = configuration["MongoDB:ConnectionString"]; - _context = context; var mongoUrl = new MongoUrl(connectionString); var client = new MongoClient(mongoUrl); // Your MongoDB connection string var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name -- 2.43.0 From f406a15508656c302bba536b1204bec7ab940362 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 15:21:48 +0530 Subject: [PATCH 11/50] Added Employee ID of creater to bucket in Employee IDs --- Marco.Pms.Services/Helpers/DirectoryHelper.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Marco.Pms.Services/Helpers/DirectoryHelper.cs b/Marco.Pms.Services/Helpers/DirectoryHelper.cs index f8e1b07..33460b2 100644 --- a/Marco.Pms.Services/Helpers/DirectoryHelper.cs +++ b/Marco.Pms.Services/Helpers/DirectoryHelper.cs @@ -1184,7 +1184,11 @@ namespace Marco.Pms.Services.Helpers var emplyeeIds = employeeBucketMappings.Select(eb => eb.EmployeeId).ToList(); List? contactBuckets = contactBucketMappings.Where(cb => cb.BucketId == bucket.Id).ToList(); AssignBucketVM bucketVM = bucket.ToAssignBucketVMFromBucket(); - bucketVM.EmployeeIds = emplyeeIds; + if (bucketVM.CreatedBy != null) + { + emplyeeIds.Add(bucketVM.CreatedBy.Id); + } + bucketVM.EmployeeIds = emplyeeIds.Distinct().ToList(); bucketVM.NumberOfContacts = contactBuckets.Count; bucketVMs.Add(bucketVM); } -- 2.43.0 From 823deb17ccbc7f61f8404e08270541e38c20e597 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 15:30:41 +0530 Subject: [PATCH 12/50] Optimized the Project Allocation API --- Marco.Pms.CacheHelper/EmployeeCache.cs | 2 +- .../Projects/ProjectAllocationVM.cs | 13 ++ .../Controllers/ProjectController.cs | 43 ++--- .../MappingProfiles/MappingProfile.cs | 6 + Marco.Pms.Services/Service/ProjectServices.cs | 167 ++++++++++-------- .../ServiceInterfaces/IProjectServices.cs | 2 + 6 files changed, 142 insertions(+), 91 deletions(-) create mode 100644 Marco.Pms.Model/ViewModels/Projects/ProjectAllocationVM.cs diff --git a/Marco.Pms.CacheHelper/EmployeeCache.cs b/Marco.Pms.CacheHelper/EmployeeCache.cs index 2211393..f7b7066 100644 --- a/Marco.Pms.CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.CacheHelper/EmployeeCache.cs @@ -97,7 +97,7 @@ namespace Marco.Pms.CacheHelper var result = await _collection.UpdateOneAsync(filter, update); - if (result.MatchedCount == 0) + if (result.ModifiedCount == 0) return false; return true; diff --git a/Marco.Pms.Model/ViewModels/Projects/ProjectAllocationVM.cs b/Marco.Pms.Model/ViewModels/Projects/ProjectAllocationVM.cs new file mode 100644 index 0000000..6d9138e --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Projects/ProjectAllocationVM.cs @@ -0,0 +1,13 @@ +namespace Marco.Pms.Model.ViewModels.Projects +{ + public class ProjectAllocationVM + { + public Guid Id { get; set; } + public Guid EmployeeId { get; set; } + public Guid? JobRoleId { get; set; } + public bool IsActive { get; set; } = true; + public Guid ProjectId { get; set; } + public DateTime AllocationDate { get; set; } + public DateTime? ReAllocationDate { get; set; } + } +} diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 0122003..b833064 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -252,28 +252,31 @@ namespace MarcoBMS.Services.Controllers return StatusCode(response.StatusCode, response); } - //[HttpPost("allocation")] - //public async Task ManageAllocation(List projectAllocationDot) - //{ - // // --- Step 1: Input Validation --- - // if (!ModelState.IsValid) - // { - // var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); - // _logger.LogWarning("Update project called with invalid model state for ID {ProjectId}. Errors: {Errors}", id, string.Join(", ", errors)); - // return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); - // } + [HttpPost("allocation")] + public async Task ManageAllocation([FromBody] List projectAllocationDot) + { + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) + { + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } - // // --- Step 2: Prepare data without I/O --- - // Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - // var response = await _projectServices.UpdateProjectAsync(id, updateProjectDto, tenantId, loggedInEmployee); - // if (response.Success) - // { - // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; - // await _signalR.SendNotificationAsync(notification); - // } - // return StatusCode(response.StatusCode, response); + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.ManageAllocationAsync(projectAllocationDot, tenantId, loggedInEmployee); + if (response.Success) + { + List employeeIds = response.Data.Select(pa => pa.EmployeeId).ToList(); + List projectIds = response.Data.Select(pa => pa.ProjectId).ToList(); - //} + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; + await _signalR.SendNotificationAsync(notification); + } + return StatusCode(response.StatusCode, response); + + } [HttpGet("assigned-projects/{employeeId}")] public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 7d627bc..3ca1271 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -43,6 +43,12 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); CreateMap(); + CreateMap() + .ForMember( + dest => dest.EmployeeId, + // Explicitly and safely convert string ProjectStatusId to Guid ProjectStatusId + opt => opt.MapFrom(src => src.EmpID)); + CreateMap(); #endregion #region ======================================================= Projects ======================================================= diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 7717584..33df2c0 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -609,85 +609,112 @@ namespace Marco.Pms.Services.Service } } - //public async Task> ManageAllocation(List projectAllocationDot, Guid tenantId, Employee loggedInEmployee) - //{ - // if (projectAllocationDot != null) - // { - // List? result = new List(); - // List employeeIds = new List(); - // List projectIds = new List(); + /// + /// Manages project allocations for a list of employees, either adding new allocations or deactivating existing ones. + /// This method is optimized to perform all database operations in a single transaction. + /// + /// The list of allocation changes to process. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing the list of processed allocations. + public async Task>> ManageAllocationAsync(List allocationsDto, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (allocationsDto == null || !allocationsDto.Any()) + { + return ApiResponse>.ErrorResponse("Invalid details.", "Allocation details list cannot be null or empty.", 400); + } - // foreach (var item in projectAllocationDot) - // { - // try - // { - // //ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); - // ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); - // ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == projectAllocation.EmployeeId - // && c.ProjectId == projectAllocation.ProjectId - // && c.ReAllocationDate == null - // && c.TenantId == tenantId).SingleOrDefaultAsync(); + _logger.LogInfo("Starting to manage {AllocationCount} allocations for user {UserId}.", allocationsDto.Count, loggedInEmployee.Id); - // if (projectAllocationFromDb != null) - // { - // _context.ProjectAllocations.Attach(projectAllocationFromDb); + // --- (Placeholder) Security Check --- + // In a real application, you would check if the loggedInEmployee has permission + // to manage allocations for ALL projects involved in this batch. + var projectIdsInBatch = allocationsDto.Select(a => a.ProjectId).Distinct().ToList(); + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageTeam, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to manage allocations for projects.", loggedInEmployee.Id); + return ApiResponse>.ErrorResponse("Access Denied.", "You do not have permission to manage one or more projects in this request.", 403); + } - // if (item.Status) - // { - // projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; - // projectAllocationFromDb.IsActive = true; - // _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; - // _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - // } - // else - // { - // projectAllocationFromDb.ReAllocationDate = DateTime.Now; - // projectAllocationFromDb.IsActive = false; - // _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; - // _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; + // --- Step 2: Fetch all relevant existing data in ONE database call --- + var employeeProjectPairs = allocationsDto.Select(a => new { a.EmpID, a.ProjectId }).ToList(); + List employeeIds = allocationsDto.Select(a => a.EmpID).Distinct().ToList(); - // employeeIds.Add(projectAllocation.EmployeeId); - // projectIds.Add(projectAllocation.ProjectId); - // } - // await _context.SaveChangesAsync(); - // var result1 = new - // { - // Id = projectAllocationFromDb.Id, - // EmployeeId = projectAllocation.EmployeeId, - // JobRoleId = projectAllocation.JobRoleId, - // IsActive = projectAllocation.IsActive, - // ProjectId = projectAllocation.ProjectId, - // AllocationDate = projectAllocation.AllocationDate, - // ReAllocationDate = projectAllocation.ReAllocationDate, - // TenantId = projectAllocation.TenantId - // }; - // result.Add(result1); - // } - // else - // { - // projectAllocation.AllocationDate = DateTime.Now; - // projectAllocation.IsActive = true; - // _context.ProjectAllocations.Add(projectAllocation); - // await _context.SaveChangesAsync(); + // Fetch all currently active allocations for the employees and projects in this batch. + // We use a dictionary for fast O(1) lookups inside the loop. + var existingAllocations = await _context.ProjectAllocations + .Where(pa => pa.TenantId == tenantId && + employeeIds.Contains(pa.EmployeeId) && + pa.ReAllocationDate == null) + .ToDictionaryAsync(pa => (pa.EmployeeId, pa.ProjectId)); - // employeeIds.Add(projectAllocation.EmployeeId); - // projectIds.Add(projectAllocation.ProjectId); - // } - // await _cache.ClearAllProjectIds(item.EmpID); + var processedAllocations = new List(); - // } - // catch (Exception ex) - // { - // return ApiResponse.ErrorResponse(ex.Message, ex, 400); - // } - // } + // --- Step 3: Process logic IN MEMORY --- + foreach (var dto in allocationsDto) + { + var key = (dto.EmpID, dto.ProjectId); + existingAllocations.TryGetValue(key, out var existingAllocation); - // return ApiResponse.SuccessResponse(result, "Data saved successfully", 200); + if (dto.Status == false) // User wants to DEACTIVATE the allocation + { + if (existingAllocation != null) + { + // Mark the existing allocation for deactivation + existingAllocation.ReAllocationDate = DateTime.UtcNow; // Use UtcNow for servers + existingAllocation.IsActive = false; + _context.ProjectAllocations.Update(existingAllocation); + processedAllocations.Add(existingAllocation); + } + // If it doesn't exist, we do nothing. The desired state is "not allocated". + } + else // User wants to ACTIVATE the allocation + { + if (existingAllocation == null) + { + // Create a new allocation because one doesn't exist + var newAllocation = _mapper.Map(dto); + newAllocation.TenantId = tenantId; + newAllocation.AllocationDate = DateTime.UtcNow; + newAllocation.IsActive = true; + _context.ProjectAllocations.Add(newAllocation); + processedAllocations.Add(newAllocation); + } + // If it already exists and is active, we do nothing. The state is already correct. + } + try + { + await _cache.ClearAllProjectIds(dto.EmpID); + _logger.LogInfo("Successfully completed cache invalidation for employee {EmployeeId}.", dto.EmpID); + } + catch (Exception ex) + { + // Log the error but don't fail the entire request, as the primary DB operation succeeded. + _logger.LogError(ex, "Cache invalidation failed for employees after a successful database update."); + } + } - // } - // return ApiResponse.ErrorResponse("Invalid details.", "Work Item Details are not valid.", 400); + try + { + // --- Step 4: Save all changes in a SINGLE TRANSACTION --- + // All Adds and Updates are sent to the database in one batch. + // If any part fails, the entire transaction is rolled back. + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully saved {ChangeCount} allocation changes to the database.", processedAllocations.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save allocation changes to the database."); + return ApiResponse>.ErrorResponse("Database Error.", "An error occurred while saving the changes.", 500); + } - //} + + // --- Step 5: Map results and return success --- + var resultVm = _mapper.Map>(processedAllocations); + return ApiResponse>.SuccessResponse(resultVm, "Allocations managed successfully.", 200); + } #endregion diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index d0539b0..2552444 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -1,6 +1,7 @@ using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Utilities; +using Marco.Pms.Model.ViewModels.Projects; namespace Marco.Pms.Services.Service.ServiceInterfaces { @@ -15,5 +16,6 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee); Task> GetEmployeeByProjectIdAsync(Guid? projectId, bool includeInactive, Guid tenantId, Employee loggedInEmployee); Task> GetProjectAllocationAsync(Guid? projectId, Guid tenantId, Employee loggedInEmployee); + Task>> ManageAllocationAsync(List projectAllocationDots, Guid tenantId, Employee loggedInEmployee); } } -- 2.43.0 From 9d0c16b88703305c379d8355b810ed47acfb7d1b Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 15:37:15 +0530 Subject: [PATCH 13/50] Added Sonar files in git ignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9491a2f..a6a47c3 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +# Sonar +/.sonarqube \ No newline at end of file -- 2.43.0 From c3da83d165d2ac3376da2bf539c22cdce2521371 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 16:37:57 +0530 Subject: [PATCH 14/50] Optimized the project allocation by employee Id Apis --- .../Controllers/ProjectController.cs | 130 +++---------- .../MappingProfiles/MappingProfile.cs | 1 + Marco.Pms.Services/Service/ProjectServices.cs | 180 ++++++++++++++++++ .../ServiceInterfaces/IProjectServices.cs | 2 + 4 files changed, 207 insertions(+), 106 deletions(-) diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index b833064..82ce0dd 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -17,7 +17,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; using MongoDB.Driver; -using Project = Marco.Pms.Model.Projects.Project; namespace MarcoBMS.Services.Controllers { @@ -281,123 +280,42 @@ namespace MarcoBMS.Services.Controllers [HttpGet("assigned-projects/{employeeId}")] public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) { - if (employeeId == Guid.Empty) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Employee id not valid.", 400)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get project list by employee Id called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - List projectList = await _context.ProjectAllocations - .Where(c => c.TenantId == tenantId && c.EmployeeId == employeeId && c.IsActive) - .Select(c => c.ProjectId).Distinct() - .ToListAsync(); - - if (!projectList.Any()) - { - return NotFound(ApiResponse.SuccessResponse(new List(), "No projects found.", 200)); - } - - - List projectlist = await _context.Projects - .Where(p => projectList.Contains(p.Id)) - .ToListAsync(); - - List projects = new List(); - - - foreach (var project in projectlist) - { - - projects.Add(project.ToProjectListVMFromProject()); - } - - - - return Ok(ApiResponse.SuccessResponse(projects, "Success.", 200)); + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectsByEmployeeAsync(employeeId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpPost("assign-projects/{employeeId}")] - public async Task AssigneProjectsToEmployee([FromBody] List projectAllocationDtos, [FromRoute] Guid employeeId) + public async Task AssigneProjectsToEmployee([FromBody] List projectAllocationDtos, [FromRoute] Guid employeeId) { - if (projectAllocationDtos != null && employeeId != Guid.Empty) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - List? result = new List(); - List projectIds = new List(); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } - foreach (var projectAllocationDto in projectAllocationDtos) - { - try - { - ProjectAllocation projectAllocation = projectAllocationDto.ToProjectAllocationFromProjectsAllocationDto(tenantId, employeeId); - ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.ProjectId == projectAllocationDto.ProjectId && c.ReAllocationDate == null && c.TenantId == tenantId).SingleOrDefaultAsync(); - - if (projectAllocationFromDb != null) - { - - - _context.ProjectAllocations.Attach(projectAllocationFromDb); - - if (projectAllocationDto.Status) - { - projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; - projectAllocationFromDb.IsActive = true; - _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - } - else - { - projectAllocationFromDb.ReAllocationDate = DateTime.UtcNow; - projectAllocationFromDb.IsActive = false; - _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - - projectIds.Add(projectAllocation.ProjectId); - } - await _context.SaveChangesAsync(); - var result1 = new - { - Id = projectAllocationFromDb.Id, - EmployeeId = projectAllocation.EmployeeId, - JobRoleId = projectAllocation.JobRoleId, - IsActive = projectAllocation.IsActive, - ProjectId = projectAllocation.ProjectId, - AllocationDate = projectAllocation.AllocationDate, - ReAllocationDate = projectAllocation.ReAllocationDate, - TenantId = projectAllocation.TenantId - }; - result.Add(result1); - } - else - { - projectAllocation.AllocationDate = DateTime.Now; - projectAllocation.IsActive = true; - _context.ProjectAllocations.Add(projectAllocation); - await _context.SaveChangesAsync(); - - projectIds.Add(projectAllocation.ProjectId); - - } - - - } - catch (Exception ex) - { - - return Ok(ApiResponse.ErrorResponse(ex.Message, ex, 400)); - } - } - await _cache.ClearAllProjectIds(employeeId); - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.AssigneProjectsToEmployeeAsync(projectAllocationDtos, employeeId, tenantId, loggedInEmployee); + if (response.Success) + { + List projectIds = response.Data.Select(pa => pa.ProjectId).ToList(); + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; await _signalR.SendNotificationAsync(notification); - - return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); } - else - { - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "All Field is required", 400)); - } - + return StatusCode(response.StatusCode, response); } #endregion diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 3ca1271..ea42d16 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -48,6 +48,7 @@ namespace Marco.Pms.Services.MappingProfiles dest => dest.EmployeeId, // Explicitly and safely convert string ProjectStatusId to Guid ProjectStatusId opt => opt.MapFrom(src => src.EmpID)); + CreateMap(); CreateMap(); #endregion diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 33df2c0..9024112 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -716,6 +716,186 @@ namespace Marco.Pms.Services.Service return ApiResponse>.SuccessResponse(resultVm, "Allocations managed successfully.", 200); } + /// + /// Retrieves a list of active projects assigned to a specific employee. + /// + /// The ID of the employee whose projects are being requested. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing a list of basic project details or an error. + public async Task> GetProjectsByEmployeeAsync(Guid employeeId, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (employeeId == Guid.Empty) + { + return ApiResponse.ErrorResponse("Invalid details.", "A valid employee ID is required.", 400); + } + + _logger.LogInfo("Fetching projects for Employee {EmployeeId} by User {UserId}", employeeId, loggedInEmployee.Id); + + try + { + // --- Step 2: Clarified Security Check --- + // The permission should be about viewing another employee's assignments, not a generic "Manage Team". + // This is a placeholder for your actual, more specific permission logic. + // It should also handle the case where a user is requesting their own projects (employeeId == loggedInEmployee.Id). + var hasPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); + var projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to view projects for employee {TargetEmployeeId}.", loggedInEmployee.Id, employeeId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to view this employee's projects.", 403); + } + + // --- Step 3: Execute a Single, Highly Efficient Database Query --- + // This query projects directly to the ViewModel on the database server. + var projects = await _context.ProjectAllocations + // 1. Filter the linking table down to the relevant records. + .Where(pa => + pa.TenantId == tenantId && + pa.EmployeeId == employeeId && // Target the specified employee + pa.IsActive && // Only active assignments + projectIds.Contains(pa.ProjectId) && + pa.Project != null) // Safety check for data integrity + + // 2. Navigate to the Project entity. + .Select(pa => pa.Project) + + // 3. Ensure the final result set is unique (in case of multiple active allocations to the same project). + .Distinct() + + // 4. Project directly to the ViewModel using AutoMapper's IQueryable Extensions. + // This generates an efficient SQL "SELECT Id, Name, Code FROM..." statement. + .ProjectTo(_mapper.ConfigurationProvider) + + // 5. Execute the query. + .ToListAsync(); + + _logger.LogInfo("Successfully retrieved {ProjectCount} projects for employee {EmployeeId}.", projects.Count, employeeId); + + // The original check for an empty list is still good practice. + if (!projects.Any()) + { + return ApiResponse.SuccessResponse(new List(), "No active projects found for this employee.", 200); + } + + return ApiResponse.SuccessResponse(projects, "Projects retrieved successfully.", 200); + } + catch (Exception ex) + { + // --- Step 4: Graceful Error Handling --- + _logger.LogError(ex, "An error occurred while fetching projects for employee {EmployeeId}.", employeeId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "Database query failed.", 500); + } + } + + /// + /// Manages project assignments for a single employee, processing a batch of projects to activate or deactivate. + /// This method is optimized to perform all database operations in a single, atomic transaction. + /// + /// A list of projects to assign or un-assign. + /// The ID of the employee whose assignments are being managed. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing the list of processed allocations. + public async Task>> AssigneProjectsToEmployeeAsync(List allocationsDto, Guid employeeId, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (allocationsDto == null || !allocationsDto.Any() || employeeId == Guid.Empty) + { + return ApiResponse>.ErrorResponse("Invalid details.", "A valid employee ID and a list of projects are required.", 400); + } + + _logger.LogInfo("Starting to manage {AllocationCount} project assignments for Employee {EmployeeId}.", allocationsDto.Count, employeeId); + + // --- (Placeholder) Security Check --- + // You MUST verify that the loggedInEmployee has permission to modify the assignments for the target employeeId. + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageTeam, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to manage assignments for employee {TargetEmployeeId}.", loggedInEmployee.Id, employeeId); + return ApiResponse>.ErrorResponse("Access Denied.", "You do not have permission to manage this employee's assignments.", 403); + } + + // --- Step 2: Fetch all relevant existing data in ONE database call --- + var projectIdsInDto = allocationsDto.Select(p => p.ProjectId).ToList(); + + // Fetch all currently active allocations for this employee for the projects in the request. + // We use a dictionary keyed by ProjectId for fast O(1) lookups inside the loop. + var existingActiveAllocations = await _context.ProjectAllocations + .Where(pa => pa.TenantId == tenantId && + pa.EmployeeId == employeeId && + projectIdsInDto.Contains(pa.ProjectId) && + pa.ReAllocationDate == null) // Only fetch active ones + .ToDictionaryAsync(pa => pa.ProjectId); + + var processedAllocations = new List(); + + // --- Step 3: Process all logic IN MEMORY, tracking changes --- + foreach (var dto in allocationsDto) + { + existingActiveAllocations.TryGetValue(dto.ProjectId, out var existingAllocation); + + if (dto.Status == false) // DEACTIVATE this project assignment + { + if (existingAllocation != null) + { + // Correct Update Pattern: Modify the fetched entity directly. + existingAllocation.ReAllocationDate = DateTime.UtcNow; // Use UTC for servers + existingAllocation.IsActive = false; + _context.ProjectAllocations.Update(existingAllocation); + processedAllocations.Add(existingAllocation); + } + // If it's not in our dictionary, it's already inactive. Do nothing. + } + else // ACTIVATE this project assignment + { + if (existingAllocation == null) + { + // Create a new allocation because an active one doesn't exist. + var newAllocation = _mapper.Map(dto); + newAllocation.EmployeeId = employeeId; + newAllocation.TenantId = tenantId; + newAllocation.AllocationDate = DateTime.UtcNow; + newAllocation.IsActive = true; + _context.ProjectAllocations.Add(newAllocation); + processedAllocations.Add(newAllocation); + } + // If it already exists in our dictionary, it's already active. Do nothing. + } + } + + try + { + // --- Step 4: Save all Adds and Updates in a SINGLE ATOMIC TRANSACTION --- + if (processedAllocations.Any()) + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully saved {ChangeCount} assignment changes for employee {EmployeeId}.", processedAllocations.Count, employeeId); + } + } + catch (DbUpdateException ex) + { + _logger.LogError(ex, "Failed to save assignment changes for employee {EmployeeId}.", employeeId); + return ApiResponse>.ErrorResponse("Database Error.", "An error occurred while saving the changes.", 500); + } + + // --- Step 5: Invalidate Cache ONCE after successful save --- + try + { + await _cache.ClearAllProjectIds(employeeId); + _logger.LogInfo("Successfully queued cache invalidation for employee {EmployeeId}.", employeeId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background cache invalidation failed for employee {EmployeeId}", employeeId); + } + + // --- Step 6: Map results using AutoMapper and return success --- + var resultVm = _mapper.Map>(processedAllocations); + return ApiResponse>.SuccessResponse(resultVm, "Assignments managed successfully.", 200); + } + #endregion #region =================================================================== Helper Functions =================================================================== diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index 2552444..bafa582 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -17,5 +17,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> GetEmployeeByProjectIdAsync(Guid? projectId, bool includeInactive, Guid tenantId, Employee loggedInEmployee); Task> GetProjectAllocationAsync(Guid? projectId, Guid tenantId, Employee loggedInEmployee); Task>> ManageAllocationAsync(List projectAllocationDots, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectsByEmployeeAsync(Guid employeeId, Guid tenantId, Employee loggedInEmployee); + Task>> AssigneProjectsToEmployeeAsync(List projectAllocationDtos, Guid employeeId, Guid tenantId, Employee loggedInEmployee); } } -- 2.43.0 From c79cbf32eab109df6d78165086fc212c74689068 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Wed, 16 Jul 2025 15:08:53 +0530 Subject: [PATCH 15/50] Optimized the manage task API in projectController --- Marco.Pms.CacheHelper/ProjectCache.cs | 33 +- .../{WorkItemDot.cs => WorkItemDto.cs} | 2 +- Marco.Pms.Model/Mapper/InfraMapper.cs | 2 +- .../Controllers/ProjectController.cs | 298 ++-------- .../Helpers/CacheUpdateHelper.cs | 17 +- Marco.Pms.Services/Helpers/GeneralHelper.cs | 214 +++++++ Marco.Pms.Services/Helpers/ProjectsHelper.cs | 4 +- .../MappingProfiles/MappingProfile.cs | 5 + Marco.Pms.Services/Program.cs | 1 + Marco.Pms.Services/Service/ProjectServices.cs | 547 +++++++++++++++++- .../ServiceInterfaces/IProjectServices.cs | 4 + 11 files changed, 826 insertions(+), 301 deletions(-) rename Marco.Pms.Model/Dtos/Projects/{WorkItemDot.cs => WorkItemDto.cs} (94%) create mode 100644 Marco.Pms.Services/Helpers/GeneralHelper.cs diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index c7d7e84..833e1a0 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -406,45 +406,22 @@ namespace Marco.Pms.CacheHelper return workItems; } - public async Task ManageWorkItemDetailsToCache(List workItems) + public async Task ManageWorkItemDetailsToCache(List workItems) { - var activityIds = workItems.Select(wi => wi.ActivityId).ToList(); - var workCategoryIds = workItems.Select(wi => wi.WorkCategoryId).ToList(); - var workItemIds = workItems.Select(wi => wi.Id).ToList(); - // fetching Activity master - var activities = await _context.ActivityMasters.Where(a => activityIds.Contains(a.Id)).ToListAsync() ?? new List(); - - // Fetching Work Category - var workCategories = await _context.WorkCategoryMasters.Where(wc => workCategoryIds.Contains(wc.Id)).ToListAsync() ?? new List(); - var task = await _context.TaskAllocations.Where(t => workItemIds.Contains(t.WorkItemId) && t.AssignmentDate == DateTime.UtcNow).ToListAsync(); - var todaysAssign = task.Sum(t => t.PlannedTask); - foreach (WorkItem workItem in workItems) + foreach (WorkItemMongoDB workItem in workItems) { - var activity = activities.FirstOrDefault(a => a.Id == workItem.ActivityId) ?? new ActivityMaster(); - var workCategory = workCategories.FirstOrDefault(a => a.Id == workItem.WorkCategoryId) ?? new WorkCategoryMaster(); - var filter = Builders.Filter.Eq(p => p.Id, workItem.Id.ToString()); var updates = Builders.Update.Combine( Builders.Update.Set(r => r.WorkAreaId, workItem.WorkAreaId.ToString()), Builders.Update.Set(r => r.ParentTaskId, (workItem.ParentTaskId != null ? workItem.ParentTaskId.ToString() : null)), Builders.Update.Set(r => r.PlannedWork, workItem.PlannedWork), - Builders.Update.Set(r => r.TodaysAssigned, todaysAssign), + Builders.Update.Set(r => r.TodaysAssigned, workItem.TodaysAssigned), Builders.Update.Set(r => r.CompletedWork, workItem.CompletedWork), Builders.Update.Set(r => r.Description, workItem.Description), Builders.Update.Set(r => r.TaskDate, workItem.TaskDate), Builders.Update.Set(r => r.ExpireAt, DateTime.UtcNow.Date.AddDays(1)), - Builders.Update.Set(r => r.ActivityMaster, new ActivityMasterMongoDB - { - Id = activity.Id.ToString(), - ActivityName = activity.ActivityName, - UnitOfMeasurement = activity.UnitOfMeasurement - }), - Builders.Update.Set(r => r.WorkCategoryMaster, new WorkCategoryMasterMongoDB - { - Id = workCategory.Id.ToString(), - Name = workCategory.Name, - Description = workCategory.Description, - }) + Builders.Update.Set(r => r.ActivityMaster, workItem.ActivityMaster), + Builders.Update.Set(r => r.WorkCategoryMaster, workItem.WorkCategoryMaster) ); var options = new UpdateOptions { IsUpsert = true }; var result = await _taskCollection.UpdateOneAsync(filter, updates, options); diff --git a/Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs b/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs similarity index 94% rename from Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs rename to Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs index e6ba436..7c98051 100644 --- a/Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs @@ -2,7 +2,7 @@ namespace Marco.Pms.Model.Dtos.Project { - public class WorkItemDot + public class WorkItemDto { [Key] public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Mapper/InfraMapper.cs b/Marco.Pms.Model/Mapper/InfraMapper.cs index 4ccb7c8..89097d1 100644 --- a/Marco.Pms.Model/Mapper/InfraMapper.cs +++ b/Marco.Pms.Model/Mapper/InfraMapper.cs @@ -48,7 +48,7 @@ namespace Marco.Pms.Model.Mapper } public static class WorkItemMapper { - public static WorkItem ToWorkItemFromWorkItemDto(this WorkItemDot model, Guid tenantId) + public static WorkItem ToWorkItemFromWorkItemDto(this WorkItemDto model, Guid tenantId) { return new WorkItem { diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 82ce0dd..a10fc66 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,9 +1,7 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; -using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Mapper; -using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Projects; @@ -325,188 +323,36 @@ namespace MarcoBMS.Services.Controllers [HttpGet("infra-details/{projectId}")] public async Task GetInfraDetails(Guid projectId) { - _logger.LogInfo("GetInfraDetails called for ProjectId: {ProjectId}", projectId); - - // Step 1: Get logged-in employee - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // Step 2: Check project-specific permission - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); - if (!hasProjectPermission) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - _logger.LogWarning("Project access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get Project Infrastructure by ProjectId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - // Step 3: Check 'ViewInfra' permission - var hasViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); - if (!hasViewInfraPermission) - { - _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have access to view infra", 403)); - } - var result = await _cache.GetBuildingInfra(projectId); - if (result == null) - { + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetInfraDetailsAsync(projectId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); - // Step 4: Fetch buildings for the project - var buildings = await _context.Buildings - .Where(b => b.ProjectId == projectId) - .ToListAsync(); - - var buildingIds = buildings.Select(b => b.Id).ToList(); - - // Step 5: Fetch floors associated with the buildings - var floors = await _context.Floor - .Where(f => buildingIds.Contains(f.BuildingId)) - .ToListAsync(); - - var floorIds = floors.Select(f => f.Id).ToList(); - - // Step 6: Fetch work areas associated with the floors - var workAreas = await _context.WorkAreas - .Where(wa => floorIds.Contains(wa.FloorId)) - .ToListAsync(); - var workAreaIds = workAreas.Select(wa => wa.Id).ToList(); - - // Step 7: Fetch work items associated with the work area - var workItems = await _context.WorkItems - .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) - .ToListAsync(); - - // Step 8: Build the infra hierarchy (Building > Floors > Work Areas) - List Buildings = new List(); - foreach (var building in buildings) - { - double buildingPlannedWorks = 0; - double buildingCompletedWorks = 0; - - var selectedFloors = floors.Where(f => f.BuildingId == building.Id).ToList(); - List Floors = new List(); - foreach (var floor in selectedFloors) - { - double floorPlannedWorks = 0; - double floorCompletedWorks = 0; - var selectedWorkAreas = workAreas.Where(wa => wa.FloorId == floor.Id).ToList(); - List WorkAreas = new List(); - foreach (var workArea in selectedWorkAreas) - { - double workAreaPlannedWorks = 0; - double workAreaCompletedWorks = 0; - var selectedWorkItems = workItems.Where(wi => wi.WorkAreaId == workArea.Id).ToList(); - foreach (var workItem in selectedWorkItems) - { - workAreaPlannedWorks += workItem.PlannedWork; - workAreaCompletedWorks += workItem.CompletedWork; - } - WorkAreaMongoDB workAreaMongo = new WorkAreaMongoDB - { - Id = workArea.Id.ToString(), - AreaName = workArea.AreaName, - PlannedWork = workAreaPlannedWorks, - CompletedWork = workAreaCompletedWorks - }; - WorkAreas.Add(workAreaMongo); - floorPlannedWorks += workAreaPlannedWorks; - floorCompletedWorks += workAreaCompletedWorks; - } - FloorMongoDB floorMongoDB = new FloorMongoDB - { - Id = floor.Id.ToString(), - FloorName = floor.FloorName, - PlannedWork = floorPlannedWorks, - CompletedWork = floorCompletedWorks, - WorkAreas = WorkAreas - }; - Floors.Add(floorMongoDB); - buildingPlannedWorks += floorPlannedWorks; - buildingCompletedWorks += floorCompletedWorks; - } - - var buildingMongo = new BuildingMongoDB - { - Id = building.Id.ToString(), - BuildingName = building.Name, - Description = building.Description, - PlannedWork = buildingPlannedWorks, - CompletedWork = buildingCompletedWorks, - Floors = Floors - }; - Buildings.Add(buildingMongo); - } - result = Buildings; - } - - _logger.LogInfo("Infra details fetched successfully for ProjectId: {ProjectId}, EmployeeId: {EmployeeId}, Buildings: {Count}", - projectId, loggedInEmployee.Id, result.Count); - - return Ok(ApiResponse.SuccessResponse(result, "Infra details fetched successfully", 200)); } [HttpGet("tasks/{workAreaId}")] public async Task GetWorkItems(Guid workAreaId) { - _logger.LogInfo("GetWorkItems called for WorkAreaId: {WorkAreaId}", workAreaId); - - // Step 1: Get the currently logged-in employee - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // Step 2: Check if the employee has ViewInfra permission - var hasViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); - if (!hasViewInfraPermission) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have permission to view infrastructure", 403)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get Work Items by WorkAreaId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - // Step 3: Check if the specified Work Area exists - var isWorkAreaExist = await _context.WorkAreas.AnyAsync(wa => wa.Id == workAreaId); - if (!isWorkAreaExist) - { - _logger.LogWarning("Work Area not found for WorkAreaId: {WorkAreaId}", workAreaId); - return NotFound(ApiResponse.ErrorResponse("Work Area not found", "Work Area not found in database", 404)); - } - - // Step 4: Fetch WorkItems with related Activity and Work Category data - var workItemVMs = await _cache.GetWorkItemDetailsByWorkArea(workAreaId); - if (workItemVMs == null) - { - var workItems = await _context.WorkItems - .Include(wi => wi.ActivityMaster) - .Include(wi => wi.WorkCategoryMaster) - .Where(wi => wi.WorkAreaId == workAreaId) - .ToListAsync(); - - workItemVMs = workItems.Select(wi => new WorkItemMongoDB - { - Id = wi.Id.ToString(), - WorkAreaId = wi.WorkAreaId.ToString(), - ParentTaskId = wi.ParentTaskId.ToString(), - ActivityMaster = new ActivityMasterMongoDB - { - Id = wi.ActivityId.ToString(), - ActivityName = wi.ActivityMaster != null ? wi.ActivityMaster.ActivityName : null, - UnitOfMeasurement = wi.ActivityMaster != null ? wi.ActivityMaster.UnitOfMeasurement : null - }, - WorkCategoryMaster = new WorkCategoryMasterMongoDB - { - Id = wi.WorkCategoryId.ToString() ?? "", - Name = wi.WorkCategoryMaster != null ? wi.WorkCategoryMaster.Name : "", - Description = wi.WorkCategoryMaster != null ? wi.WorkCategoryMaster.Description : "" - }, - PlannedWork = wi.PlannedWork, - CompletedWork = wi.CompletedWork, - Description = wi.Description, - TaskDate = wi.TaskDate, - }).ToList(); - - await _cache.ManageWorkItemDetails(workItems); - } - - _logger.LogInfo("{Count} work items fetched successfully for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); - - // Step 5: Return result - return Ok(ApiResponse.SuccessResponse(workItemVMs, $"{workItemVMs.Count} records of tasks fetched successfully", 200)); + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetWorkItemsAsync(workAreaId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } #endregion @@ -514,107 +360,29 @@ namespace MarcoBMS.Services.Controllers #region =================================================================== Project Infrastructre Manage APIs =================================================================== [HttpPost("task")] - public async Task CreateProjectTask(List workItemDtos) + public async Task CreateProjectTask([FromBody] List workItemDtos) { - _logger.LogInfo("CreateProjectTask called with {Count} items", workItemDtos?.Count ?? 0); - - // Validate request - if (workItemDtos == null || !workItemDtos.Any()) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - _logger.LogWarning("No work items provided in the request."); - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Work Item details are not valid.", 400)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - var workItemsToCreate = new List(); - var workItemsToUpdate = new List(); - var responseList = new List(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - string message = ""; - List workAreaIds = new List(); - var workItemIds = workItemDtos.Where(wi => wi.Id != null && wi.Id != Guid.Empty).Select(wi => wi.Id).ToList(); - var workItems = await _context.WorkItems.AsNoTracking().Where(wi => workItemIds.Contains(wi.Id)).ToListAsync(); - - foreach (var itemDto in workItemDtos) + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.CreateProjectTaskAsync(workItemDtos, tenantId, loggedInEmployee); + if (response.Success) { - var workItem = itemDto.ToWorkItemFromWorkItemDto(tenantId); - var workArea = await _context.WorkAreas.Include(a => a.Floor).FirstOrDefaultAsync(a => a.Id == workItem.WorkAreaId) ?? new WorkArea(); - - Building building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == (workArea.Floor != null ? workArea.Floor.BuildingId : Guid.Empty)) ?? new Building(); - - if (itemDto.Id != null && itemDto.Id != Guid.Empty) - { - // Update existing - workItemsToUpdate.Add(workItem); - message = $"Task Updated in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; - var existingWorkItem = workItems.FirstOrDefault(wi => wi.Id == workItem.Id); - double plannedWork = 0; - double completedWork = 0; - if (existingWorkItem != null) - { - if (existingWorkItem.PlannedWork != workItem.PlannedWork && existingWorkItem.CompletedWork != workItem.CompletedWork) - { - plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; - completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; - } - else if (existingWorkItem.PlannedWork == workItem.PlannedWork && existingWorkItem.CompletedWork != workItem.CompletedWork) - { - plannedWork = 0; - completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; - } - else if (existingWorkItem.PlannedWork != workItem.PlannedWork && existingWorkItem.CompletedWork == workItem.CompletedWork) - { - plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; - completedWork = 0; - } - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, plannedWork, completedWork); - } - } - else - { - // Create new - workItem.Id = Guid.NewGuid(); - workItemsToCreate.Add(workItem); - message = $"Task Added in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, workItem.PlannedWork, workItem.CompletedWork); - } - - responseList.Add(new WorkItemVM - { - WorkItemId = workItem.Id, - WorkItem = workItem - }); - workAreaIds.Add(workItem.WorkAreaId); + List workAreaIds = response.Data.Select(pa => pa.WorkItem?.WorkAreaId ?? Guid.Empty).ToList(); + string message = response.Message; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = message }; + await _signalR.SendNotificationAsync(notification); } - string responseMessage = ""; - // Apply DB changes - if (workItemsToCreate.Any()) - { - _logger.LogInfo("Adding {Count} new work items", workItemsToCreate.Count); - await _context.WorkItems.AddRangeAsync(workItemsToCreate); - responseMessage = "Task Added Successfully"; - await _cache.ManageWorkItemDetails(workItemsToCreate); - } + return StatusCode(response.StatusCode, response); - if (workItemsToUpdate.Any()) - { - _logger.LogInfo("Updating {Count} existing work items", workItemsToUpdate.Count); - _context.WorkItems.UpdateRange(workItemsToUpdate); - responseMessage = "Task Updated Successfully"; - await _cache.ManageWorkItemDetails(workItemsToUpdate); - } - - await _context.SaveChangesAsync(); - - _logger.LogInfo("CreateProjectTask completed successfully. Created: {Created}, Updated: {Updated}", workItemsToCreate.Count, workItemsToUpdate.Count); - - - - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = message }; - - await _signalR.SendNotificationAsync(notification); - - return Ok(ApiResponse.SuccessResponse(responseList, responseMessage, 200)); } [HttpDelete("task/{id}")] diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index aca439b..9a01b83 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -17,9 +17,10 @@ namespace Marco.Pms.Services.Helpers private readonly ILoggingService _logger; private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; + private readonly GeneralHelper _generalHelper; public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ReportCache reportCache, ILoggingService logger, - IDbContextFactory dbContextFactory, ApplicationDbContext context) + IDbContextFactory dbContextFactory, ApplicationDbContext context, GeneralHelper generalHelper) { _projectCache = projectCache; _employeeCache = employeeCache; @@ -27,6 +28,7 @@ namespace Marco.Pms.Services.Helpers _logger = logger; _dbContextFactory = dbContextFactory; _context = context; + _generalHelper = generalHelper; } // ------------------------------------ Project Details Cache --------------------------------------- @@ -563,6 +565,19 @@ namespace Marco.Pms.Services.Helpers } } public async Task ManageWorkItemDetails(List workItems) + { + try + { + var workAreaId = workItems.First().WorkAreaId; + var workItemDB = await _generalHelper.GetWorkItemsListFromDB(workAreaId); + await _projectCache.ManageWorkItemDetailsToCache(workItemDB); + } + catch (Exception ex) + { + _logger.LogWarning("Error occured while saving workItems form Cache: {Error}", ex.Message); + } + } + public async Task ManageWorkItemDetailsByVM(List workItems) { try { diff --git a/Marco.Pms.Services/Helpers/GeneralHelper.cs b/Marco.Pms.Services/Helpers/GeneralHelper.cs new file mode 100644 index 0000000..c2f8fe4 --- /dev/null +++ b/Marco.Pms.Services/Helpers/GeneralHelper.cs @@ -0,0 +1,214 @@ +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.MongoDBModels; +using MarcoBMS.Services.Service; +using Microsoft.EntityFrameworkCore; + +namespace Marco.Pms.Services.Helpers +{ + public class GeneralHelper + { + private readonly IDbContextFactory _dbContextFactory; + private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate + private readonly ILoggingService _logger; + public GeneralHelper(IDbContextFactory dbContextFactory, + ApplicationDbContext context, + ILoggingService logger) + { + _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + public async Task> GetProjectInfraFromDB(Guid projectId) + { + // Each task uses its own DbContext instance for thread safety. Projections are used for efficiency. + + // Task to fetch Buildings, Floors, and WorkAreas using projections + var hierarchyTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + var buildings = await context.Buildings.AsNoTracking().Where(b => b.ProjectId == projectId).Select(b => new { b.Id, b.Name, b.Description }).ToListAsync(); + var buildingIds = buildings.Select(b => b.Id).ToList(); + var floors = await context.Floor.AsNoTracking().Where(f => buildingIds.Contains(f.BuildingId)).Select(f => new { f.Id, f.BuildingId, f.FloorName }).ToListAsync(); + var floorIds = floors.Select(f => f.Id).ToList(); + var workAreas = await context.WorkAreas.AsNoTracking().Where(wa => floorIds.Contains(wa.FloorId)).Select(wa => new { wa.Id, wa.FloorId, wa.AreaName }).ToListAsync(); + return (buildings, floors, workAreas); + }); + + // Task to get work summaries, AGGREGATED ON THE DATABASE SERVER + var workSummaryTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + // This is the most powerful optimization. It avoids pulling all WorkItem rows. + return await context.WorkItems.AsNoTracking() + .Where(wi => wi.WorkArea != null && wi.WorkArea.Floor != null && wi.WorkArea.Floor.Building != null && wi.WorkArea.Floor.Building.ProjectId == projectId) + .GroupBy(wi => wi.WorkAreaId) // Group by the parent WorkArea + .Select(g => new + { + WorkAreaId = g.Key, + PlannedWork = g.Sum(i => i.PlannedWork), + CompletedWork = g.Sum(i => i.CompletedWork) + }) + .ToDictionaryAsync(x => x.WorkAreaId); // Return a ready-to-use dictionary for fast lookups + }); + + await Task.WhenAll(hierarchyTask, workSummaryTask); + + var (buildings, floors, workAreas) = await hierarchyTask; + var workSummariesByWorkAreaId = await workSummaryTask; + + // --- Step 4: Build the hierarchy efficiently using Lookups --- + // Using lookups is much faster (O(1)) than repeated .Where() calls (O(n)). + var floorsByBuildingId = floors.ToLookup(f => f.BuildingId); + var workAreasByFloorId = workAreas.ToLookup(wa => wa.FloorId); + + var buildingMongoList = new List(); + foreach (var building in buildings) + { + double buildingPlanned = 0, buildingCompleted = 0; + var floorMongoList = new List(); + + foreach (var floor in floorsByBuildingId[building.Id]) // Fast lookup + { + double floorPlanned = 0, floorCompleted = 0; + var workAreaMongoList = new List(); + + foreach (var workArea in workAreasByFloorId[floor.Id]) // Fast lookup + { + // Get the pre-calculated summary from the dictionary. O(1) operation. + workSummariesByWorkAreaId.TryGetValue(workArea.Id, out var summary); + var waPlanned = summary?.PlannedWork ?? 0; + var waCompleted = summary?.CompletedWork ?? 0; + + workAreaMongoList.Add(new WorkAreaMongoDB + { + Id = workArea.Id.ToString(), + AreaName = workArea.AreaName, + PlannedWork = waPlanned, + CompletedWork = waCompleted + }); + + floorPlanned += waPlanned; + floorCompleted += waCompleted; + } + + floorMongoList.Add(new FloorMongoDB + { + Id = floor.Id.ToString(), + FloorName = floor.FloorName, + PlannedWork = floorPlanned, + CompletedWork = floorCompleted, + WorkAreas = workAreaMongoList + }); + + buildingPlanned += floorPlanned; + buildingCompleted += floorCompleted; + } + + buildingMongoList.Add(new BuildingMongoDB + { + Id = building.Id.ToString(), + BuildingName = building.Name, + Description = building.Description, + PlannedWork = buildingPlanned, + CompletedWork = buildingCompleted, + Floors = floorMongoList + }); + } + return buildingMongoList; + } + + /// + /// Retrieves a list of work items for a specific work area, including a summary of tasks assigned for the current day. + /// This method is highly optimized to run database operations in parallel and perform aggregations on the server. + /// + /// The ID of the work area. + /// A list of WorkItemMongoDB objects with calculated daily assignments. + public async Task> GetWorkItemsListFromDB(Guid workAreaId) + { + _logger.LogInfo("Fetching DB work items for WorkAreaId: {WorkAreaId}", workAreaId); + + try + { + // --- Step 1: Run independent database queries in PARALLEL --- + // We can fetch the WorkItems and the aggregated TaskAllocations at the same time. + + // Task 1: Fetch the WorkItem entities and their related data. + var workItemsTask = _context.WorkItems + .Include(wi => wi.ActivityMaster) + .Include(wi => wi.WorkCategoryMaster) + .Where(wi => wi.WorkAreaId == workAreaId) + .AsNoTracking() + .ToListAsync(); + + // Task 2: Fetch and AGGREGATE today's task allocations ON THE DATABASE SERVER. + var todaysAssignmentsTask = Task.Run(async () => + { + // Correctly define "today's" date range to avoid precision issues. + var today = DateTime.UtcNow.Date; + var tomorrow = today.AddDays(1); + + using var context = _dbContextFactory.CreateDbContext(); // Use a factory for thread safety + + // This is the most powerful optimization: + // 1. It filters by WorkAreaId directly, making it independent of the first query. + // 2. It filters by a correct date range. + // 3. It groups and sums on the DB server, returning only a small summary. + return await context.TaskAllocations + .Where(t => t.WorkItem != null && t.WorkItem.WorkAreaId == workAreaId && + t.AssignmentDate >= today && t.AssignmentDate < tomorrow) + .GroupBy(t => t.WorkItemId) + .Select(g => new + { + WorkItemId = g.Key, + TodaysAssigned = g.Sum(x => x.PlannedTask) + }) + // Return a dictionary for instant O(1) lookups later. + .ToDictionaryAsync(x => x.WorkItemId, x => x.TodaysAssigned); + }); + + // Await both parallel database operations to complete. + await Task.WhenAll(workItemsTask, todaysAssignmentsTask); + + // Retrieve the results from the completed tasks. + var workItemsFromDb = await workItemsTask; + var todaysAssignments = await todaysAssignmentsTask; + + // --- Step 2: Map to the ViewModel/MongoDB model efficiently --- + var workItemVMs = workItemsFromDb.Select(wi => new WorkItemMongoDB + { + Id = wi.Id.ToString(), + WorkAreaId = wi.WorkAreaId.ToString(), + ParentTaskId = wi.ParentTaskId.ToString(), + ActivityMaster = wi.ActivityMaster != null ? new ActivityMasterMongoDB + { + Id = wi.ActivityMaster.Id.ToString(), + ActivityName = wi.ActivityMaster.ActivityName, + UnitOfMeasurement = wi.ActivityMaster.UnitOfMeasurement + } : null, + WorkCategoryMaster = wi.WorkCategoryMaster != null ? new WorkCategoryMasterMongoDB + { + Id = wi.WorkCategoryMaster.Id.ToString(), + Name = wi.WorkCategoryMaster.Name, + Description = wi.WorkCategoryMaster.Description + } : null, + PlannedWork = wi.PlannedWork, + CompletedWork = wi.CompletedWork, + Description = wi.Description, + TaskDate = wi.TaskDate, + // Use the fast dictionary lookup instead of the slow in-memory Where/Sum. + TodaysAssigned = todaysAssignments.GetValueOrDefault(wi.Id, 0) + }).ToList(); + + _logger.LogInfo("Successfully processed {WorkItemCount} work items for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); + + return workItemVMs; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching DB work items for WorkAreaId: {WorkAreaId}", workAreaId); + // Return an empty list or re-throw, depending on your application's error handling strategy. + return new List(); + } + } + } +} diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs index fe70a0a..e7e1dd6 100644 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ b/Marco.Pms.Services/Helpers/ProjectsHelper.cs @@ -11,14 +11,12 @@ namespace MarcoBMS.Services.Helpers public class ProjectsHelper { private readonly ApplicationDbContext _context; - private readonly RolesHelper _rolesHelper; private readonly CacheUpdateHelper _cache; private readonly PermissionServices _permission; - public ProjectsHelper(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache, PermissionServices permission) + public ProjectsHelper(ApplicationDbContext context, CacheUpdateHelper cache, PermissionServices permission) { _context = context; - _rolesHelper = rolesHelper; _cache = cache; _permission = permission; } diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index ea42d16..50d2ea9 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -50,6 +50,11 @@ namespace Marco.Pms.Services.MappingProfiles opt => opt.MapFrom(src => src.EmpID)); CreateMap(); CreateMap(); + + CreateMap() + .ForMember( + dest => dest.Description, + opt => opt.MapFrom(src => src.Comment)); #endregion #region ======================================================= Projects ======================================================= diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 26d8eba..3c73416 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -163,6 +163,7 @@ builder.Services.AddScoped(); #endregion #region Helpers +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 9024112..6d811fc 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -29,6 +29,7 @@ namespace Marco.Pms.Services.Service private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; private readonly IMapper _mapper; + private readonly GeneralHelper _generalHelper; public ProjectServices( IDbContextFactory dbContextFactory, ApplicationDbContext context, @@ -36,7 +37,8 @@ namespace Marco.Pms.Services.Service ProjectsHelper projectsHelper, PermissionServices permission, CacheUpdateHelper cache, - IMapper mapper) + IMapper mapper, + GeneralHelper generalHelper) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); _context = context ?? throw new ArgumentNullException(nameof(context)); @@ -45,6 +47,7 @@ namespace Marco.Pms.Services.Service _permission = permission ?? throw new ArgumentNullException(nameof(permission)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + _generalHelper = generalHelper ?? throw new ArgumentNullException(nameof(generalHelper)); } #region =================================================================== Project Get APIs =================================================================== @@ -898,6 +901,525 @@ namespace Marco.Pms.Services.Service #endregion + #region =================================================================== Project InfraStructure Get APIs =================================================================== + + /// + /// Retrieves the full infrastructure hierarchy (Buildings, Floors, Work Areas) for a project, + /// including aggregated work summaries. + /// + public async Task> GetInfraDetailsAsync(Guid projectId, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("GetInfraDetails called for ProjectId: {ProjectId}", projectId); + + try + { + // --- Step 1: Run independent permission checks in PARALLEL --- + var projectPermissionTask = _permission.HasProjectPermission(loggedInEmployee, projectId); + var viewInfraPermissionTask = _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); + + await Task.WhenAll(projectPermissionTask, viewInfraPermissionTask); + + if (!await projectPermissionTask) + { + _logger.LogWarning("Project access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); + return ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403); + } + if (!await viewInfraPermissionTask) + { + _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Access denied", "You don't have access to view this project's infrastructure", 403); + } + + // --- Step 2: Cache-First Strategy --- + var cachedResult = await _cache.GetBuildingInfra(projectId); + if (cachedResult != null) + { + _logger.LogInfo("Cache HIT for infra details for ProjectId: {ProjectId}", projectId); + return ApiResponse.SuccessResponse(cachedResult, "Infra details fetched successfully from cache.", 200); + } + + _logger.LogInfo("Cache MISS for infra details for ProjectId: {ProjectId}. Fetching from database.", projectId); + + // --- Step 3: Fetch all required data from the database --- + + var buildingMongoList = await _generalHelper.GetProjectInfraFromDB(projectId); + // --- Step 5: Proactively update the cache --- + //await _cache.SetBuildingInfra(projectId, buildingMongoList); + + _logger.LogInfo("Infra details fetched successfully for ProjectId: {ProjectId}, Buildings: {Count}", projectId, buildingMongoList.Count); + return ApiResponse.SuccessResponse(buildingMongoList, "Infra details fetched successfully", 200); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching infra details for ProjectId: {ProjectId}", projectId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "An error occurred while processing your request.", 500); + } + } + + /// + /// Retrieves a list of work items for a specific work area, ensuring the user has appropriate permissions. + /// + /// The ID of the work area. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing a list of work items or an error. + public async Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("GetWorkItems called for WorkAreaId: {WorkAreaId} by User: {UserId}", workAreaId, loggedInEmployee.Id); + + try + { + // --- Step 1: Cache-First Strategy --- + var cachedWorkItems = await _cache.GetWorkItemDetailsByWorkArea(workAreaId); + if (cachedWorkItems != null) + { + _logger.LogInfo("Cache HIT for WorkAreaId: {WorkAreaId}. Returning {Count} items from cache.", workAreaId, cachedWorkItems.Count); + return ApiResponse.SuccessResponse(cachedWorkItems, $"{cachedWorkItems.Count} tasks retrieved successfully from cache.", 200); + } + + _logger.LogInfo("Cache MISS for WorkAreaId: {WorkAreaId}. Fetching from database.", workAreaId); + + // --- Step 2: Security Check First --- + // This pattern remains the most robust: verify permissions before fetching a large list. + var projectInfo = await _context.WorkAreas + .Where(wa => wa.Id == workAreaId && wa.TenantId == tenantId && wa.Floor != null && wa.Floor.Building != null) + .Select(wa => new { wa.Floor!.Building!.ProjectId }) + .FirstOrDefaultAsync(); + + if (projectInfo == null) + { + _logger.LogWarning("Work Area not found for WorkAreaId: {WorkAreaId}", workAreaId); + return ApiResponse.ErrorResponse("Not Found", $"Work Area with ID {workAreaId} not found.", 404); + } + + var hasProjectAccess = await _permission.HasProjectPermission(loggedInEmployee, projectInfo.ProjectId); + var hasGenericViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); + + if (!hasProjectAccess || !hasGenericViewInfraPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} on WorkAreaId {WorkAreaId}.", loggedInEmployee.Id, workAreaId); + return ApiResponse.ErrorResponse("Access Denied", "You do not have sufficient permissions to view these work items.", 403); + } + + // --- Step 3: Fetch Full Entities for Caching and Mapping --- + var workItemVMs = await _generalHelper.GetWorkItemsListFromDB(workAreaId); + + // --- Step 5: Proactively Update the Cache with the Correct Object Type --- + // We now pass the 'workItemsFromDb' list, which is the required List. + + try + { + await _cache.ManageWorkItemDetailsByVM(workItemVMs); + _logger.LogInfo("Successfully queued cache update for WorkAreaId: {WorkAreaId}", workAreaId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background cache update failed for WorkAreaId: {WorkAreaId}", workAreaId); + } + + + _logger.LogInfo("{Count} work items fetched successfully for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); + return ApiResponse.SuccessResponse(workItemVMs, $"{workItemVMs.Count} tasks fetched successfully.", 200); + } + catch (Exception ex) + { + // --- Step 6: Graceful Error Handling --- + _logger.LogError(ex, "An unexpected error occurred while getting work items for WorkAreaId: {WorkAreaId}", workAreaId); + return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); + } + } + + #endregion + + #region =================================================================== Project Infrastructre Manage APIs =================================================================== + + public async Task> CreateProjectTask1(List workItemDtos, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("CreateProjectTask called with {Count} items", workItemDtos?.Count ?? 0); + + // Validate request + if (workItemDtos == null || !workItemDtos.Any()) + { + _logger.LogWarning("No work items provided in the request."); + return ApiResponse.ErrorResponse("Invalid details.", "Work Item details are not valid.", 400); + } + + var workItemsToCreate = new List(); + var workItemsToUpdate = new List(); + var responseList = new List(); + string message = ""; + List workAreaIds = new List(); + var workItemIds = workItemDtos.Where(wi => wi.Id != null && wi.Id != Guid.Empty).Select(wi => wi.Id).ToList(); + var workItems = await _context.WorkItems.AsNoTracking().Where(wi => workItemIds.Contains(wi.Id)).ToListAsync(); + + foreach (var itemDto in workItemDtos) + { + var workItem = _mapper.Map(itemDto); + workItem.TenantId = tenantId; + var workArea = await _context.WorkAreas.Include(a => a.Floor).FirstOrDefaultAsync(a => a.Id == workItem.WorkAreaId) ?? new WorkArea(); + + Building building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == (workArea.Floor != null ? workArea.Floor.BuildingId : Guid.Empty)) ?? new Building(); + + if (itemDto.Id != null && itemDto.Id != Guid.Empty) + { + // Update existing + workItemsToUpdate.Add(workItem); + message = $"Task Updated in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + var existingWorkItem = workItems.FirstOrDefault(wi => wi.Id == workItem.Id); + if (existingWorkItem != null) + { + double plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; + double completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; + await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, plannedWork, completedWork); + } + } + else + { + // Create new + workItem.Id = Guid.NewGuid(); + workItemsToCreate.Add(workItem); + message = $"Task Added in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, workItem.PlannedWork, workItem.CompletedWork); + } + + responseList.Add(new WorkItemVM + { + WorkItemId = workItem.Id, + WorkItem = workItem + }); + workAreaIds.Add(workItem.WorkAreaId); + + } + // Apply DB changes + if (workItemsToCreate.Any()) + { + _logger.LogInfo("Adding {Count} new work items", workItemsToCreate.Count); + await _context.WorkItems.AddRangeAsync(workItemsToCreate); + await _cache.ManageWorkItemDetails(workItemsToCreate); + } + + if (workItemsToUpdate.Any()) + { + _logger.LogInfo("Updating {Count} existing work items", workItemsToUpdate.Count); + _context.WorkItems.UpdateRange(workItemsToUpdate); + await _cache.ManageWorkItemDetails(workItemsToUpdate); + } + + await _context.SaveChangesAsync(); + + _logger.LogInfo("CreateProjectTask completed successfully. Created: {Created}, Updated: {Updated}", workItemsToCreate.Count, workItemsToUpdate.Count); + + return ApiResponse.SuccessResponse(responseList, message, 200); + } + + /// + /// Creates or updates a batch of work items. + /// This method is optimized to perform all database operations in a single, atomic transaction. + /// + public async Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("CreateProjectTask called with {Count} items by user {UserId}", workItemDtos?.Count ?? 0, loggedInEmployee.Id); + + // --- Step 1: Input Validation --- + if (workItemDtos == null || !workItemDtos.Any()) + { + _logger.LogWarning("No work items provided in the request."); + return ApiResponse>.ErrorResponse("Invalid details.", "Work Item details list cannot be empty.", 400); + } + + // --- Step 2: Fetch all required existing data in bulk --- + var workAreaIds = workItemDtos.Select(d => d.WorkAreaID).Distinct().ToList(); + var workItemIdsToUpdate = workItemDtos.Where(d => d.Id.HasValue).Select(d => d.Id!.Value).ToList(); + + // Fetch all relevant WorkAreas and their parent hierarchy in ONE query + var workAreasFromDb = await _context.WorkAreas + .Where(wa => wa.Floor != null && wa.Floor.Building != null && workAreaIds.Contains(wa.Id) && wa.TenantId == tenantId) + .Include(wa => wa.Floor!.Building) // Eagerly load the entire path + .ToDictionaryAsync(wa => wa.Id); // Dictionary for fast lookups + + // Fetch all existing WorkItems that need updating in ONE query + var existingWorkItemsToUpdate = await _context.WorkItems + .Where(wi => workItemIdsToUpdate.Contains(wi.Id) && wi.TenantId == tenantId) + .ToDictionaryAsync(wi => wi.Id); // Dictionary for fast lookups + + // --- (Placeholder) Security Check --- + // You MUST verify the user has permission to modify ALL WorkAreas in the batch. + var projectIdsInBatch = workAreasFromDb.Values.Select(wa => wa.Floor!.Building!.ProjectId).Distinct(); + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProjectInfra, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to create/update tasks.", loggedInEmployee.Id); + return ApiResponse>.ErrorResponse("Access Denied.", "You do not have permission to modify tasks in one or more of the specified work areas.", 403); + } + + var workItemsToCreate = new List(); + var workItemsToModify = new List(); + var workDeltaForCache = new Dictionary(); // WorkAreaId -> (Delta) + string message = ""; + + // --- Step 3: Process all logic IN MEMORY, tracking changes --- + foreach (var dto in workItemDtos) + { + if (!workAreasFromDb.TryGetValue(dto.WorkAreaID, out var workArea)) + { + _logger.LogWarning("Skipping item because WorkAreaId {WorkAreaId} was not found or is invalid.", dto.WorkAreaID); + continue; // Skip this item as its parent WorkArea is invalid + } + + if (dto.Id.HasValue && existingWorkItemsToUpdate.TryGetValue(dto.Id.Value, out var existingWorkItem)) + { + // --- UPDATE Logic --- + var plannedDelta = dto.PlannedWork - existingWorkItem.PlannedWork; + var completedDelta = dto.CompletedWork - existingWorkItem.CompletedWork; + + // Apply changes from DTO to the fetched entity to prevent data loss + _mapper.Map(dto, existingWorkItem); + workItemsToModify.Add(existingWorkItem); + + // Track the change in work for cache update + workDeltaForCache[workArea.Id] = ( + workDeltaForCache.GetValueOrDefault(workArea.Id).Planned + plannedDelta, + workDeltaForCache.GetValueOrDefault(workArea.Id).Completed + completedDelta + ); + message = $"Task Updated in Building: {workArea.Floor?.Building?.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + } + else + { + // --- CREATE Logic --- + var newWorkItem = _mapper.Map(dto); + newWorkItem.Id = Guid.NewGuid(); // Ensure new GUID is set + newWorkItem.TenantId = tenantId; + workItemsToCreate.Add(newWorkItem); + + // Track the change in work for cache update + workDeltaForCache[workArea.Id] = ( + workDeltaForCache.GetValueOrDefault(workArea.Id).Planned + newWorkItem.PlannedWork, + workDeltaForCache.GetValueOrDefault(workArea.Id).Completed + newWorkItem.CompletedWork + ); + message = $"Task Added in Building: {workArea.Floor?.Building?.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + } + } + + try + { + // --- Step 4: Save all database changes in a SINGLE TRANSACTION --- + if (workItemsToCreate.Any()) _context.WorkItems.AddRange(workItemsToCreate); + if (workItemsToModify.Any()) _context.WorkItems.UpdateRange(workItemsToModify); // EF Core handles individual updates correctly here + + if (workItemsToCreate.Any() || workItemsToModify.Any()) + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully saved {CreatedCount} new and {UpdatedCount} updated work items.", workItemsToCreate.Count, workItemsToModify.Count); + + // --- Step 5: Update Cache and SignalR AFTER successful DB save (non-blocking) --- + var allAffectedItems = workItemsToCreate.Concat(workItemsToModify).ToList(); + _ = Task.Run(async () => + { + await UpdateCacheAndNotify(workDeltaForCache, allAffectedItems); + }); + } + } + catch (DbUpdateException ex) + { + _logger.LogError(ex, "A database error occurred while creating/updating tasks."); + return ApiResponse>.ErrorResponse("Database Error", "Failed to save changes.", 500); + } + + // --- Step 6: Prepare and return the response --- + var allProcessedItems = workItemsToCreate.Concat(workItemsToModify).ToList(); + var responseList = allProcessedItems.Select(wi => new WorkItemVM + { + WorkItemId = wi.Id, + WorkItem = wi + }).ToList(); + + + return ApiResponse>.SuccessResponse(responseList, message, 200); + } + + + //public async Task DeleteProjectTask(Guid id) + //{ + // var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + // List workAreaIds = new List(); + // WorkItem? task = await _context.WorkItems.AsNoTracking().Include(t => t.WorkArea).FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); + // if (task != null) + // { + // if (task.CompletedWork == 0) + // { + // var assignedTask = await _context.TaskAllocations.Where(t => t.WorkItemId == id).ToListAsync(); + // if (assignedTask.Count == 0) + // { + // _context.WorkItems.Remove(task); + // await _context.SaveChangesAsync(); + // _logger.LogInfo("Task with ID {WorkItemId} has been successfully deleted.", task.Id); + + // var floorId = task.WorkArea?.FloorId; + // var floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == floorId); + + + // workAreaIds.Add(task.WorkAreaId); + // var projectId = floor?.Building?.ProjectId; + + // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = $"Task Deleted in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}, in Area: {task.WorkArea?.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}" }; + // await _signalR.SendNotificationAsync(notification); + // await _cache.DeleteWorkItemByIdAsync(task.Id); + // if (projectId != null) + // { + // await _cache.DeleteProjectByIdAsync(projectId.Value); + // } + // } + // else + // { + // _logger.LogWarning("Task with ID {WorkItemId} is currently assigned and cannot be deleted.", task.Id); + // return BadRequest(ApiResponse.ErrorResponse("Task is currently assigned and cannot be deleted.", "Task is currently assigned and cannot be deleted.", 400)); + // } + // } + // else + // { + // double percentage = (task.CompletedWork / task.PlannedWork) * 100; + // percentage = Math.Round(percentage, 2); + // _logger.LogWarning("Task with ID {WorkItemId} is {CompletionPercentage}% complete and cannot be deleted", task.Id, percentage); + // return BadRequest(ApiResponse.ErrorResponse(System.String.Format("Task is {0}% complete and cannot be deleted", percentage), System.String.Format("Task is {0}% complete and cannot be deleted", percentage), 400)); + + // } + // } + // else + // { + // _logger.LogWarning("Task with ID {WorkItemId} not found ID database", id); + // } + // return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); + //} + + //public async Task ManageProjectInfra(List infraDots) + //{ + // var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // var responseData = new InfraVM { }; + // string responseMessage = ""; + // string message = ""; + // List projectIds = new List(); + // if (infraDots != null) + // { + // foreach (var item in infraDots) + // { + // if (item.Building != null) + // { + + // Building building = item.Building.ToBuildingFromBuildingDto(tenantId); + // building.TenantId = tenantId; + + // if (item.Building.Id == null) + // { + // //create + // _context.Buildings.Add(building); + // await _context.SaveChangesAsync(); + // responseData.building = building; + // responseMessage = "Buliding Added Successfully"; + // message = "Building Added"; + // await _cache.AddBuildngInfra(building.ProjectId, building); + // } + // else + // { + // //update + // _context.Buildings.Update(building); + // await _context.SaveChangesAsync(); + // responseData.building = building; + // responseMessage = "Buliding Updated Successfully"; + // message = "Building Updated"; + // await _cache.UpdateBuildngInfra(building.ProjectId, building); + // } + // projectIds.Add(building.ProjectId); + // } + // if (item.Floor != null) + // { + // Floor floor = item.Floor.ToFloorFromFloorDto(tenantId); + // floor.TenantId = tenantId; + // bool isCreated = false; + + // if (item.Floor.Id == null) + // { + // //create + // _context.Floor.Add(floor); + // await _context.SaveChangesAsync(); + // responseData.floor = floor; + // responseMessage = "Floor Added Successfully"; + // message = "Floor Added"; + // isCreated = true; + // } + // else + // { + // //update + // _context.Floor.Update(floor); + // await _context.SaveChangesAsync(); + // responseData.floor = floor; + // responseMessage = "Floor Updated Successfully"; + // message = "Floor Updated"; + // } + // Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); + // var projectId = building?.ProjectId ?? Guid.Empty; + // projectIds.Add(projectId); + // message = $"{message} in Building: {building?.Name}"; + // if (isCreated) + // { + // await _cache.AddBuildngInfra(projectId, floor: floor); + // } + // else + // { + // await _cache.UpdateBuildngInfra(projectId, floor: floor); + // } + // } + // if (item.WorkArea != null) + // { + // WorkArea workArea = item.WorkArea.ToWorkAreaFromWorkAreaDto(tenantId); + // workArea.TenantId = tenantId; + // bool isCreated = false; + + // if (item.WorkArea.Id == null) + // { + // //create + // _context.WorkAreas.Add(workArea); + // await _context.SaveChangesAsync(); + // responseData.workArea = workArea; + // responseMessage = "Work Area Added Successfully"; + // message = "Work Area Added"; + // isCreated = true; + // } + // else + // { + // //update + // _context.WorkAreas.Update(workArea); + // await _context.SaveChangesAsync(); + // responseData.workArea = workArea; + // responseMessage = "Work Area Updated Successfully"; + // message = "Work Area Updated"; + // } + // Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); + // var projectId = floor?.Building?.ProjectId ?? Guid.Empty; + // projectIds.Add(projectId); + // message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; + // if (isCreated) + // { + // await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); + // } + // else + // { + // await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); + // } + // } + // } + // message = $"{message} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; + + // await _signalR.SendNotificationAsync(notification); + // return Ok(ApiResponse.SuccessResponse(responseData, responseMessage, 200)); + // } + // return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400)); + + //} + + #endregion + #region =================================================================== Helper Functions =================================================================== /// @@ -1101,7 +1623,6 @@ namespace Marco.Pms.Services.Service return dbProject; } - // Helper method for background cache update private async Task UpdateCacheInBackground(Project project) { try @@ -1120,6 +1641,28 @@ namespace Marco.Pms.Services.Service } } + private async Task UpdateCacheAndNotify(Dictionary workDelta, List affectedItems) + { + try + { + // Update planned/completed work totals + var cacheUpdateTasks = workDelta.Select(kvp => + _cache.UpdatePlannedAndCompleteWorksInBuilding(kvp.Key, kvp.Value.Planned, kvp.Value.Completed)); + await Task.WhenAll(cacheUpdateTasks); + _logger.LogInfo("Background cache work totals update completed for {AreaCount} areas.", workDelta.Count); + + // Update the details of the individual work items in the cache + await _cache.ManageWorkItemDetails(affectedItems); + _logger.LogInfo("Background cache work item details update completed for {ItemCount} items.", affectedItems.Count); + + // Add SignalR notification logic here if needed + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred during background cache update/notification."); + } + } + #endregion } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index bafa582..2db004d 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -19,5 +19,9 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task>> ManageAllocationAsync(List projectAllocationDots, Guid tenantId, Employee loggedInEmployee); Task> GetProjectsByEmployeeAsync(Guid employeeId, Guid tenantId, Employee loggedInEmployee); Task>> AssigneProjectsToEmployeeAsync(List projectAllocationDtos, Guid employeeId, Guid tenantId, Employee loggedInEmployee); + Task> GetInfraDetailsAsync(Guid projectId, Guid tenantId, Employee loggedInEmployee); + Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee); + Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee); + } } -- 2.43.0 From 3f7925aa72e06174b0beefa4b914c5cf221bb9c6 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Wed, 16 Jul 2025 18:15:43 +0530 Subject: [PATCH 16/50] Optimized the Manage infra API in Project Controller --- Marco.Pms.CacheHelper/ProjectCache.cs | 7 + .../{BuildingDot.cs => BuildingDto.cs} | 2 +- .../Projects/{FloorDot.cs => FloorDto.cs} | 2 +- Marco.Pms.Model/Dtos/Projects/InfraDot.cs | 9 - Marco.Pms.Model/Dtos/Projects/InfraDto.cs | 9 + .../{WorkAreaDot.cs => WorkAreaDto.cs} | 2 +- Marco.Pms.Model/Mapper/InfraMapper.cs | 6 +- Marco.Pms.Model/Utilities/ServiceResponse.cs | 8 + .../Controllers/ProjectController.cs | 154 +---- .../Helpers/CacheUpdateHelper.cs | 12 + .../MappingProfiles/MappingProfile.cs | 3 + Marco.Pms.Services/Service/ProjectServices.cs | 612 ++++++++++++------ .../ServiceInterfaces/IProjectServices.cs | 1 + 13 files changed, 488 insertions(+), 339 deletions(-) rename Marco.Pms.Model/Dtos/Projects/{BuildingDot.cs => BuildingDto.cs} (92%) rename Marco.Pms.Model/Dtos/Projects/{FloorDot.cs => FloorDto.cs} (92%) delete mode 100644 Marco.Pms.Model/Dtos/Projects/InfraDot.cs create mode 100644 Marco.Pms.Model/Dtos/Projects/InfraDto.cs rename Marco.Pms.Model/Dtos/Projects/{WorkAreaDot.cs => WorkAreaDto.cs} (91%) create mode 100644 Marco.Pms.Model/Utilities/ServiceResponse.cs diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index 833e1a0..9417724 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -95,6 +95,13 @@ namespace Marco.Pms.CacheHelper var result = await _projetCollection.DeleteOneAsync(filter); return result.DeletedCount > 0; } + public async Task RemoveProjectsFromCacheAsync(List projectIds) + { + var stringIds = projectIds.Select(id => id.ToString()).ToList(); + var filter = Builders.Filter.In(p => p.Id, stringIds); + var result = await _projetCollection.DeleteManyAsync(filter); + return result.DeletedCount > 0; + } // ------------------------------------------------------- Project InfraStructure ------------------------------------------------------- diff --git a/Marco.Pms.Model/Dtos/Projects/BuildingDot.cs b/Marco.Pms.Model/Dtos/Projects/BuildingDto.cs similarity index 92% rename from Marco.Pms.Model/Dtos/Projects/BuildingDot.cs rename to Marco.Pms.Model/Dtos/Projects/BuildingDto.cs index a5b160b..e6a7b89 100644 --- a/Marco.Pms.Model/Dtos/Projects/BuildingDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/BuildingDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace Marco.Pms.Model.Dtos.Project { - public class BuildingDot + public class BuildingDto { [Key] public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Dtos/Projects/FloorDot.cs b/Marco.Pms.Model/Dtos/Projects/FloorDto.cs similarity index 92% rename from Marco.Pms.Model/Dtos/Projects/FloorDot.cs rename to Marco.Pms.Model/Dtos/Projects/FloorDto.cs index a3d1c86..3dbe06f 100644 --- a/Marco.Pms.Model/Dtos/Projects/FloorDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/FloorDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace Marco.Pms.Model.Dtos.Project { - public class FloorDot + public class FloorDto { public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Dtos/Projects/InfraDot.cs b/Marco.Pms.Model/Dtos/Projects/InfraDot.cs deleted file mode 100644 index 7c16c09..0000000 --- a/Marco.Pms.Model/Dtos/Projects/InfraDot.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Marco.Pms.Model.Dtos.Project -{ - public class InfraDot - { - public BuildingDot? Building { get; set; } - public FloorDot? Floor { get; set; } - public WorkAreaDot? WorkArea { get; set; } - } -} diff --git a/Marco.Pms.Model/Dtos/Projects/InfraDto.cs b/Marco.Pms.Model/Dtos/Projects/InfraDto.cs new file mode 100644 index 0000000..09d1462 --- /dev/null +++ b/Marco.Pms.Model/Dtos/Projects/InfraDto.cs @@ -0,0 +1,9 @@ +namespace Marco.Pms.Model.Dtos.Project +{ + public class InfraDto + { + public BuildingDto? Building { get; set; } + public FloorDto? Floor { get; set; } + public WorkAreaDto? WorkArea { get; set; } + } +} diff --git a/Marco.Pms.Model/Dtos/Projects/WorkAreaDot.cs b/Marco.Pms.Model/Dtos/Projects/WorkAreaDto.cs similarity index 91% rename from Marco.Pms.Model/Dtos/Projects/WorkAreaDot.cs rename to Marco.Pms.Model/Dtos/Projects/WorkAreaDto.cs index 604ee3e..ffc80c4 100644 --- a/Marco.Pms.Model/Dtos/Projects/WorkAreaDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/WorkAreaDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace Marco.Pms.Model.Dtos.Project { - public class WorkAreaDot + public class WorkAreaDto { [Key] public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Mapper/InfraMapper.cs b/Marco.Pms.Model/Mapper/InfraMapper.cs index 89097d1..5364494 100644 --- a/Marco.Pms.Model/Mapper/InfraMapper.cs +++ b/Marco.Pms.Model/Mapper/InfraMapper.cs @@ -5,7 +5,7 @@ namespace Marco.Pms.Model.Mapper { public static class BuildingMapper { - public static Building ToBuildingFromBuildingDto(this BuildingDot model, Guid tenantId) + public static Building ToBuildingFromBuildingDto(this BuildingDto model, Guid tenantId) { return new Building { @@ -20,7 +20,7 @@ namespace Marco.Pms.Model.Mapper public static class FloorMapper { - public static Floor ToFloorFromFloorDto(this FloorDot model, Guid tenantId) + public static Floor ToFloorFromFloorDto(this FloorDto model, Guid tenantId) { return new Floor { @@ -34,7 +34,7 @@ namespace Marco.Pms.Model.Mapper public static class WorAreaMapper { - public static WorkArea ToWorkAreaFromWorkAreaDto(this WorkAreaDot model, Guid tenantId) + public static WorkArea ToWorkAreaFromWorkAreaDto(this WorkAreaDto model, Guid tenantId) { return new WorkArea { diff --git a/Marco.Pms.Model/Utilities/ServiceResponse.cs b/Marco.Pms.Model/Utilities/ServiceResponse.cs new file mode 100644 index 0000000..a76c45c --- /dev/null +++ b/Marco.Pms.Model/Utilities/ServiceResponse.cs @@ -0,0 +1,8 @@ +namespace Marco.Pms.Model.Utilities +{ + public class ServiceResponse + { + public object? Notification { get; set; } + public ApiResponse Response { get; set; } = ApiResponse.ErrorResponse(""); + } +} diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index a10fc66..71ef1a5 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,10 +1,8 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; -using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; -using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service; using Marco.Pms.Services.Service.ServiceInterfaces; @@ -359,6 +357,30 @@ namespace MarcoBMS.Services.Controllers #region =================================================================== Project Infrastructre Manage APIs =================================================================== + [HttpPost("manage-infra")] + public async Task ManageProjectInfra(List infraDtos) + { + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) + { + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } + + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var serviceResponse = await _projectServices.ManageProjectInfraAsync(infraDtos, tenantId, loggedInEmployee); + var response = serviceResponse.Response; + var notification = serviceResponse.Notification; + if (notification != null) + { + await _signalR.SendNotificationAsync(notification); + } + return StatusCode(response.StatusCode, response); + + } + [HttpPost("task")] public async Task CreateProjectTask([FromBody] List workItemDtos) { @@ -439,134 +461,6 @@ namespace MarcoBMS.Services.Controllers return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); } - [HttpPost("manage-infra")] - public async Task ManageProjectInfra(List infraDots) - { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - var responseData = new InfraVM { }; - string responseMessage = ""; - string message = ""; - List projectIds = new List(); - if (infraDots != null) - { - foreach (var item in infraDots) - { - if (item.Building != null) - { - - Building building = item.Building.ToBuildingFromBuildingDto(tenantId); - building.TenantId = tenantId; - - if (item.Building.Id == null) - { - //create - _context.Buildings.Add(building); - await _context.SaveChangesAsync(); - responseData.building = building; - responseMessage = "Buliding Added Successfully"; - message = "Building Added"; - await _cache.AddBuildngInfra(building.ProjectId, building); - } - else - { - //update - _context.Buildings.Update(building); - await _context.SaveChangesAsync(); - responseData.building = building; - responseMessage = "Buliding Updated Successfully"; - message = "Building Updated"; - await _cache.UpdateBuildngInfra(building.ProjectId, building); - } - projectIds.Add(building.ProjectId); - } - if (item.Floor != null) - { - Floor floor = item.Floor.ToFloorFromFloorDto(tenantId); - floor.TenantId = tenantId; - bool isCreated = false; - - if (item.Floor.Id == null) - { - //create - _context.Floor.Add(floor); - await _context.SaveChangesAsync(); - responseData.floor = floor; - responseMessage = "Floor Added Successfully"; - message = "Floor Added"; - isCreated = true; - } - else - { - //update - _context.Floor.Update(floor); - await _context.SaveChangesAsync(); - responseData.floor = floor; - responseMessage = "Floor Updated Successfully"; - message = "Floor Updated"; - } - Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); - var projectId = building?.ProjectId ?? Guid.Empty; - projectIds.Add(projectId); - message = $"{message} in Building: {building?.Name}"; - if (isCreated) - { - await _cache.AddBuildngInfra(projectId, floor: floor); - } - else - { - await _cache.UpdateBuildngInfra(projectId, floor: floor); - } - } - if (item.WorkArea != null) - { - WorkArea workArea = item.WorkArea.ToWorkAreaFromWorkAreaDto(tenantId); - workArea.TenantId = tenantId; - bool isCreated = false; - - if (item.WorkArea.Id == null) - { - //create - _context.WorkAreas.Add(workArea); - await _context.SaveChangesAsync(); - responseData.workArea = workArea; - responseMessage = "Work Area Added Successfully"; - message = "Work Area Added"; - isCreated = true; - } - else - { - //update - _context.WorkAreas.Update(workArea); - await _context.SaveChangesAsync(); - responseData.workArea = workArea; - responseMessage = "Work Area Updated Successfully"; - message = "Work Area Updated"; - } - Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); - var projectId = floor?.Building?.ProjectId ?? Guid.Empty; - projectIds.Add(projectId); - message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; - if (isCreated) - { - await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - } - else - { - await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - } - } - } - message = $"{message} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; - - await _signalR.SendNotificationAsync(notification); - return Ok(ApiResponse.SuccessResponse(responseData, responseMessage, 200)); - } - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400)); - - } - #endregion } diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 9a01b83..b0b1e06 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -478,6 +478,18 @@ namespace Marco.Pms.Services.Helpers } } + public async Task RemoveProjectsAsync(List projectIds) + { + try + { + var response = await _projectCache.RemoveProjectsFromCacheAsync(projectIds); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while deleting project list from to Cache"); + + } + } // ------------------------------------ Project Infrastructure Cache --------------------------------------- diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 50d2ea9..bf3777c 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -51,6 +51,9 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); CreateMap() .ForMember( dest => dest.Description, diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 6d811fc..32e1285 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -1033,83 +1033,360 @@ namespace Marco.Pms.Services.Service #region =================================================================== Project Infrastructre Manage APIs =================================================================== - public async Task> CreateProjectTask1(List workItemDtos, Guid tenantId, Employee loggedInEmployee) + public async Task> ManageProjectInfra(List infraDots, Guid tenantId, Employee loggedInEmployee) { - _logger.LogInfo("CreateProjectTask called with {Count} items", workItemDtos?.Count ?? 0); - - // Validate request - if (workItemDtos == null || !workItemDtos.Any()) - { - _logger.LogWarning("No work items provided in the request."); - return ApiResponse.ErrorResponse("Invalid details.", "Work Item details are not valid.", 400); - } - - var workItemsToCreate = new List(); - var workItemsToUpdate = new List(); - var responseList = new List(); + var responseData = new InfraVM { }; + string responseMessage = ""; string message = ""; - List workAreaIds = new List(); - var workItemIds = workItemDtos.Where(wi => wi.Id != null && wi.Id != Guid.Empty).Select(wi => wi.Id).ToList(); - var workItems = await _context.WorkItems.AsNoTracking().Where(wi => workItemIds.Contains(wi.Id)).ToListAsync(); - - foreach (var itemDto in workItemDtos) + List projectIds = new List(); + if (infraDots != null) { - var workItem = _mapper.Map(itemDto); - workItem.TenantId = tenantId; - var workArea = await _context.WorkAreas.Include(a => a.Floor).FirstOrDefaultAsync(a => a.Id == workItem.WorkAreaId) ?? new WorkArea(); - - Building building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == (workArea.Floor != null ? workArea.Floor.BuildingId : Guid.Empty)) ?? new Building(); - - if (itemDto.Id != null && itemDto.Id != Guid.Empty) + foreach (var item in infraDots) { - // Update existing - workItemsToUpdate.Add(workItem); - message = $"Task Updated in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; - var existingWorkItem = workItems.FirstOrDefault(wi => wi.Id == workItem.Id); - if (existingWorkItem != null) + if (item.Building != null) { - double plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; - double completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, plannedWork, completedWork); + + Building building = _mapper.Map(item.Building); + building.TenantId = tenantId; + + if (item.Building.Id == null) + { + //create + _context.Buildings.Add(building); + await _context.SaveChangesAsync(); + responseData.building = building; + responseMessage = "Buliding Added Successfully"; + message = "Building Added"; + await _cache.AddBuildngInfra(building.ProjectId, building); + } + else + { + //update + _context.Buildings.Update(building); + await _context.SaveChangesAsync(); + responseData.building = building; + responseMessage = "Buliding Updated Successfully"; + message = "Building Updated"; + await _cache.UpdateBuildngInfra(building.ProjectId, building); + } + projectIds.Add(building.ProjectId); + } + if (item.Floor != null) + { + Floor floor = _mapper.Map(item.Floor); + floor.TenantId = tenantId; + bool isCreated = false; + + if (item.Floor.Id == null) + { + //create + _context.Floor.Add(floor); + await _context.SaveChangesAsync(); + responseData.floor = floor; + responseMessage = "Floor Added Successfully"; + message = "Floor Added"; + isCreated = true; + } + else + { + //update + _context.Floor.Update(floor); + await _context.SaveChangesAsync(); + responseData.floor = floor; + responseMessage = "Floor Updated Successfully"; + message = "Floor Updated"; + } + Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); + var projectId = building?.ProjectId ?? Guid.Empty; + projectIds.Add(projectId); + message = $"{message} in Building: {building?.Name}"; + if (isCreated) + { + await _cache.AddBuildngInfra(projectId, floor: floor); + } + else + { + await _cache.UpdateBuildngInfra(projectId, floor: floor); + } + } + if (item.WorkArea != null) + { + WorkArea workArea = _mapper.Map(item.WorkArea); + workArea.TenantId = tenantId; + bool isCreated = false; + + if (item.WorkArea.Id == null) + { + //create + _context.WorkAreas.Add(workArea); + await _context.SaveChangesAsync(); + responseData.workArea = workArea; + responseMessage = "Work Area Added Successfully"; + message = "Work Area Added"; + isCreated = true; + } + else + { + //update + _context.WorkAreas.Update(workArea); + await _context.SaveChangesAsync(); + responseData.workArea = workArea; + responseMessage = "Work Area Updated Successfully"; + message = "Work Area Updated"; + } + Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); + var projectId = floor?.Building?.ProjectId ?? Guid.Empty; + projectIds.Add(projectId); + message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; + if (isCreated) + { + await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); + } + else + { + await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); + } } } - else + message = $"{message} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; + + return ApiResponse.SuccessResponse(responseData, responseMessage, 200); + } + return ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400); + + } + + public async Task ManageProjectInfraAsync(List infraDtos, Guid tenantId, Employee loggedInEmployee) + { + // 1. Guard Clause: Handle null or empty input gracefully. + if (infraDtos == null || !infraDtos.Any()) + { + return new ServiceResponse { - // Create new - workItem.Id = Guid.NewGuid(); - workItemsToCreate.Add(workItem); - message = $"Task Added in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, workItem.PlannedWork, workItem.CompletedWork); + Response = ApiResponse.ErrorResponse("Invalid details.", "No infrastructure details were provided.", 400) + }; + } + + var responseData = new InfraVM(); + var messages = new List(); + var projectIds = new HashSet(); // Use HashSet for automatic duplicate handling. + var cacheUpdateTasks = new List(); + + // --- Pre-fetch parent entities to avoid N+1 query problem --- + // 2. Gather all parent IDs needed for validation and context. + var requiredBuildingIds = infraDtos + .Where(i => i.Floor?.BuildingId != null) + .Select(i => i.Floor!.BuildingId) + .Distinct() + .ToList(); + + var requiredFloorIds = infraDtos + .Where(i => i.WorkArea?.FloorId != null) + .Select(i => i.WorkArea!.FloorId) + .Distinct() + .ToList(); + + // 3. Fetch all required parent entities in single batch queries. + var buildingsDict = await _context.Buildings + .Where(b => requiredBuildingIds.Contains(b.Id)) + .ToDictionaryAsync(b => b.Id); + + var floorsDict = await _context.Floor + .Include(f => f.Building) // Eagerly load Building for later use + .Where(f => requiredFloorIds.Contains(f.Id)) + .ToDictionaryAsync(f => f.Id); + // --- End Pre-fetching --- + + // 4. Process all entities and add them to the context's change tracker. + foreach (var item in infraDtos) + { + if (item.Building != null) + { + ProcessBuilding(item.Building, tenantId, responseData, messages, projectIds, cacheUpdateTasks); + } + if (item.Floor != null) + { + ProcessFloor(item.Floor, tenantId, responseData, messages, projectIds, cacheUpdateTasks, buildingsDict); + } + if (item.WorkArea != null) + { + ProcessWorkArea(item.WorkArea, tenantId, responseData, messages, projectIds, cacheUpdateTasks, floorsDict); + } + } + + // 5. Save all changes to the database in a single transaction. + var changedRecordCount = await _context.SaveChangesAsync(); + + // If no changes were actually made, we can exit early. + if (changedRecordCount == 0) + { + return new ServiceResponse + { + Response = ApiResponse.SuccessResponse(responseData, "No changes detected in the provided infrastructure details.", 200) + }; + } + + // 6. Execute all cache updates concurrently after the DB save is successful. + await Task.WhenAll(cacheUpdateTasks); + + // 7. Consolidate messages and create notification payload. + string finalResponseMessage = messages.LastOrDefault() ?? "Infrastructure managed successfully."; + string logMessage = $"{string.Join(", ", messages)} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds.ToList(), Message = logMessage }; + + // TODO: Dispatch the 'notification' object to your notification service. + + return new ServiceResponse + { + Notification = notification, + Response = ApiResponse.SuccessResponse(responseData, finalResponseMessage, 200) + }; + } + + /// + /// Manages a batch of infrastructure changes (creates/updates for Buildings, Floors, and WorkAreas). + /// This method is optimized to perform all database operations in a single, atomic transaction. + /// + public async Task> ManageProjectInfraAsync1(List infraDtos, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (infraDtos == null || !infraDtos.Any()) + { + _logger.LogWarning("ManageProjectInfraAsync called with null or empty DTO list."); + return ApiResponse.ErrorResponse("Invalid details.", "Infrastructure data cannot be empty.", 400); + } + + _logger.LogInfo("Begin ManageProjectInfraAsync for {DtoCount} items, TenantId: {TenantId}, User: {UserId}", infraDtos.Count, tenantId, loggedInEmployee.Id); + + // --- Step 2: Categorize DTOs by Type and Action --- + var buildingsToCreateDto = infraDtos.Where(i => i.Building != null && i.Building.Id == null).Select(i => i.Building!).ToList(); + var buildingsToUpdateDto = infraDtos.Where(i => i.Building != null && i.Building.Id != null).Select(i => i.Building!).ToList(); + var floorsToCreateDto = infraDtos.Where(i => i.Floor != null && i.Floor.Id == null).Select(i => i.Floor!).ToList(); + var floorsToUpdateDto = infraDtos.Where(i => i.Floor != null && i.Floor.Id != null).Select(i => i.Floor!).ToList(); + var workAreasToCreateDto = infraDtos.Where(i => i.WorkArea != null && i.WorkArea.Id == null).Select(i => i.WorkArea!).ToList(); + var workAreasToUpdateDto = infraDtos.Where(i => i.WorkArea != null && i.WorkArea.Id != null).Select(i => i.WorkArea!).ToList(); + + _logger.LogDebug("Categorized DTOs..."); + + try + { + // --- Step 3: Fetch all required existing data in bulk --- + + // Fetch existing entities to be updated + var buildingIdsToUpdate = buildingsToUpdateDto.Select(d => d.Id!.Value).ToList(); + var existingBuildings = await _context.Buildings.Where(b => buildingIdsToUpdate.Contains(b.Id) && b.TenantId == tenantId).ToDictionaryAsync(b => b.Id); + + var floorIdsToUpdate = floorsToUpdateDto.Select(d => d.Id!.Value).ToList(); + var existingFloors = await _context.Floor.Include(f => f.Building).Where(f => floorIdsToUpdate.Contains(f.Id) && f.TenantId == tenantId).ToDictionaryAsync(f => f.Id); + + var workAreaIdsToUpdate = workAreasToUpdateDto.Select(d => d.Id!.Value).ToList(); + var existingWorkAreas = await _context.WorkAreas.Include(wa => wa.Floor!.Building).Where(wa => workAreaIdsToUpdate.Contains(wa.Id) && wa.TenantId == tenantId).ToDictionaryAsync(wa => wa.Id); + + // Fetch parent entities for items being created to get their ProjectIds + var buildingIdsForNewFloors = floorsToCreateDto.Select(f => f.BuildingId).ToList(); + var parentBuildingsForNewFloors = await _context.Buildings.Where(b => buildingIdsForNewFloors.Contains(b.Id)).ToDictionaryAsync(b => b.Id); + + var floorIdsForNewWorkAreas = workAreasToCreateDto.Select(wa => wa.FloorId).ToList(); + var parentFloorsForNewWorkAreas = await _context.Floor.Include(f => f.Building).Where(f => floorIdsForNewWorkAreas.Contains(f.Id)).ToDictionaryAsync(f => f.Id); + + _logger.LogInfo("Fetched existing entities and parents for new items."); + + // --- Step 4: Aggregate all affected ProjectIds for Security Check --- + var affectedProjectIds = new HashSet(); + + // From buildings being created/updated + buildingsToCreateDto.ForEach(b => affectedProjectIds.Add(b.ProjectId)); + foreach (var b in existingBuildings.Values) { affectedProjectIds.Add(b.ProjectId); } + + // From floors being created/updated + foreach (var f in floorsToCreateDto) { if (parentBuildingsForNewFloors.TryGetValue(f.BuildingId, out var b)) affectedProjectIds.Add(b.ProjectId); } + foreach (var f in existingFloors.Values) { if (f.Building != null) affectedProjectIds.Add(f.Building.ProjectId); } + + // From work areas being created/updated + foreach (var wa in workAreasToCreateDto) { if (parentFloorsForNewWorkAreas.TryGetValue(wa.FloorId, out var f) && f.Building != null) affectedProjectIds.Add(f.Building.ProjectId); } + foreach (var wa in existingWorkAreas.Values) { if (wa.Floor?.Building != null) affectedProjectIds.Add(wa.Floor.Building.ProjectId); } + + // Security Check against the complete list of affected projects + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProjectInfra, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to manage infrastructure for projects.", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to manage infrastructure for one or more of the specified projects.", 403); } - responseList.Add(new WorkItemVM + // --- Step 5: Process all logic IN MEMORY, tracking changes --- + + // Process Buildings + var createdBuildings = new List(); + foreach (var dto in buildingsToCreateDto) { - WorkItemId = workItem.Id, - WorkItem = workItem - }); - workAreaIds.Add(workItem.WorkAreaId); + var newBuilding = _mapper.Map(dto); + newBuilding.TenantId = tenantId; + createdBuildings.Add(newBuilding); + } + foreach (var dto in buildingsToUpdateDto) { if (existingBuildings.TryGetValue(dto.Id!.Value, out var b)) _mapper.Map(dto, b); } + // Process Floors + var createdFloors = new List(); + foreach (var dto in floorsToCreateDto) + { + var newFloor = _mapper.Map(dto); + newFloor.TenantId = tenantId; + createdFloors.Add(newFloor); + } + foreach (var dto in floorsToUpdateDto) { if (existingFloors.TryGetValue(dto.Id!.Value, out var f)) _mapper.Map(dto, f); } + + // Process WorkAreas + var createdWorkAreas = new List(); + foreach (var dto in workAreasToCreateDto) + { + var newWorkArea = _mapper.Map(dto); + newWorkArea.TenantId = tenantId; + createdWorkAreas.Add(newWorkArea); + } + foreach (var dto in workAreasToUpdateDto) { if (existingWorkAreas.TryGetValue(dto.Id!.Value, out var wa)) _mapper.Map(dto, wa); } + + // --- Step 6: Save all database changes in a SINGLE TRANSACTION --- + if (createdBuildings.Any()) _context.Buildings.AddRange(createdBuildings); + if (createdFloors.Any()) _context.Floor.AddRange(createdFloors); + if (createdWorkAreas.Any()) _context.WorkAreas.AddRange(createdWorkAreas); + + if (_context.ChangeTracker.HasChanges()) + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Database save successful."); + } + + // --- Step 7: Update Cache using the aggregated ProjectIds (Non-blocking) --- + var finalProjectIds = affectedProjectIds.ToList(); + if (finalProjectIds.Any()) + { + _ = Task.Run(async () => + { + try + { + _logger.LogInfo("Queuing background cache update for {ProjectCount} projects.", finalProjectIds.Count); + // Assuming your cache service has a method to handle this. + await _cache.RemoveProjectsAsync(finalProjectIds); + _logger.LogInfo("Background cache update task completed for projects: {ProjectIds}", string.Join(", ", finalProjectIds)); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred during the background cache update task for projects: {ProjectIds}", string.Join(", ", finalProjectIds)); + } + }); + } + + // --- Step 8: Prepare and return a clear response --- + var responseVm = new { /* ... as before ... */ }; + return ApiResponse.SuccessResponse(responseVm, "Infrastructure changes processed successfully.", 200); } - // Apply DB changes - if (workItemsToCreate.Any()) + catch (Exception ex) { - _logger.LogInfo("Adding {Count} new work items", workItemsToCreate.Count); - await _context.WorkItems.AddRangeAsync(workItemsToCreate); - await _cache.ManageWorkItemDetails(workItemsToCreate); + _logger.LogError(ex, "An unexpected error occurred in ManageProjectInfraAsync."); + return ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500); } - - if (workItemsToUpdate.Any()) - { - _logger.LogInfo("Updating {Count} existing work items", workItemsToUpdate.Count); - _context.WorkItems.UpdateRange(workItemsToUpdate); - await _cache.ManageWorkItemDetails(workItemsToUpdate); - } - - await _context.SaveChangesAsync(); - - _logger.LogInfo("CreateProjectTask completed successfully. Created: {Created}, Updated: {Updated}", workItemsToCreate.Count, workItemsToUpdate.Count); - - return ApiResponse.SuccessResponse(responseList, message, 200); } /// @@ -1211,12 +1488,10 @@ namespace Marco.Pms.Services.Service await _context.SaveChangesAsync(); _logger.LogInfo("Successfully saved {CreatedCount} new and {UpdatedCount} updated work items.", workItemsToCreate.Count, workItemsToModify.Count); - // --- Step 5: Update Cache and SignalR AFTER successful DB save (non-blocking) --- + // --- Step 5: Update Cache and SignalR AFTER successful DB save --- var allAffectedItems = workItemsToCreate.Concat(workItemsToModify).ToList(); - _ = Task.Run(async () => - { - await UpdateCacheAndNotify(workDeltaForCache, allAffectedItems); - }); + + await UpdateCacheAndNotify(workDeltaForCache, allAffectedItems); } } catch (DbUpdateException ex) @@ -1291,133 +1566,6 @@ namespace Marco.Pms.Services.Service // return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); //} - //public async Task ManageProjectInfra(List infraDots) - //{ - // var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // var responseData = new InfraVM { }; - // string responseMessage = ""; - // string message = ""; - // List projectIds = new List(); - // if (infraDots != null) - // { - // foreach (var item in infraDots) - // { - // if (item.Building != null) - // { - - // Building building = item.Building.ToBuildingFromBuildingDto(tenantId); - // building.TenantId = tenantId; - - // if (item.Building.Id == null) - // { - // //create - // _context.Buildings.Add(building); - // await _context.SaveChangesAsync(); - // responseData.building = building; - // responseMessage = "Buliding Added Successfully"; - // message = "Building Added"; - // await _cache.AddBuildngInfra(building.ProjectId, building); - // } - // else - // { - // //update - // _context.Buildings.Update(building); - // await _context.SaveChangesAsync(); - // responseData.building = building; - // responseMessage = "Buliding Updated Successfully"; - // message = "Building Updated"; - // await _cache.UpdateBuildngInfra(building.ProjectId, building); - // } - // projectIds.Add(building.ProjectId); - // } - // if (item.Floor != null) - // { - // Floor floor = item.Floor.ToFloorFromFloorDto(tenantId); - // floor.TenantId = tenantId; - // bool isCreated = false; - - // if (item.Floor.Id == null) - // { - // //create - // _context.Floor.Add(floor); - // await _context.SaveChangesAsync(); - // responseData.floor = floor; - // responseMessage = "Floor Added Successfully"; - // message = "Floor Added"; - // isCreated = true; - // } - // else - // { - // //update - // _context.Floor.Update(floor); - // await _context.SaveChangesAsync(); - // responseData.floor = floor; - // responseMessage = "Floor Updated Successfully"; - // message = "Floor Updated"; - // } - // Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); - // var projectId = building?.ProjectId ?? Guid.Empty; - // projectIds.Add(projectId); - // message = $"{message} in Building: {building?.Name}"; - // if (isCreated) - // { - // await _cache.AddBuildngInfra(projectId, floor: floor); - // } - // else - // { - // await _cache.UpdateBuildngInfra(projectId, floor: floor); - // } - // } - // if (item.WorkArea != null) - // { - // WorkArea workArea = item.WorkArea.ToWorkAreaFromWorkAreaDto(tenantId); - // workArea.TenantId = tenantId; - // bool isCreated = false; - - // if (item.WorkArea.Id == null) - // { - // //create - // _context.WorkAreas.Add(workArea); - // await _context.SaveChangesAsync(); - // responseData.workArea = workArea; - // responseMessage = "Work Area Added Successfully"; - // message = "Work Area Added"; - // isCreated = true; - // } - // else - // { - // //update - // _context.WorkAreas.Update(workArea); - // await _context.SaveChangesAsync(); - // responseData.workArea = workArea; - // responseMessage = "Work Area Updated Successfully"; - // message = "Work Area Updated"; - // } - // Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); - // var projectId = floor?.Building?.ProjectId ?? Guid.Empty; - // projectIds.Add(projectId); - // message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; - // if (isCreated) - // { - // await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - // } - // else - // { - // await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - // } - // } - // } - // message = $"{message} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; - // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; - - // await _signalR.SendNotificationAsync(notification); - // return Ok(ApiResponse.SuccessResponse(responseData, responseMessage, 200)); - // } - // return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400)); - - //} - #endregion #region =================================================================== Helper Functions =================================================================== @@ -1663,6 +1811,82 @@ namespace Marco.Pms.Services.Service } } + private void ProcessBuilding(BuildingDto dto, Guid tenantId, InfraVM responseData, List messages, ISet projectIds, List cacheTasks) + { + Building building = _mapper.Map(dto); + building.TenantId = tenantId; + + bool isNew = dto.Id == null; + if (isNew) + { + _context.Buildings.Add(building); + messages.Add("Building Added"); + cacheTasks.Add(_cache.AddBuildngInfra(building.ProjectId, building)); + } + else + { + _context.Buildings.Update(building); + messages.Add("Building Updated"); + cacheTasks.Add(_cache.UpdateBuildngInfra(building.ProjectId, building)); + } + + responseData.building = building; + projectIds.Add(building.ProjectId); + } + + private void ProcessFloor(FloorDto dto, Guid tenantId, InfraVM responseData, List messages, ISet projectIds, List cacheTasks, IDictionary buildings) + { + Floor floor = _mapper.Map(dto); + floor.TenantId = tenantId; + + // Use the pre-fetched dictionary for parent lookup. + Building? parentBuilding = buildings.TryGetValue(dto.BuildingId, out var b) ? b : null; + + bool isNew = dto.Id == null; + if (isNew) + { + _context.Floor.Add(floor); + messages.Add($"Floor Added in Building: {parentBuilding?.Name ?? "Unknown"}"); + cacheTasks.Add(_cache.AddBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, floor: floor)); + } + else + { + _context.Floor.Update(floor); + messages.Add($"Floor Updated in Building: {parentBuilding?.Name ?? "Unknown"}"); + cacheTasks.Add(_cache.UpdateBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, floor: floor)); + } + + responseData.floor = floor; + if (parentBuilding != null) projectIds.Add(parentBuilding.ProjectId); + } + + private void ProcessWorkArea(WorkAreaDto dto, Guid tenantId, InfraVM responseData, List messages, ISet projectIds, List cacheTasks, IDictionary floors) + { + WorkArea workArea = _mapper.Map(dto); + workArea.TenantId = tenantId; + + // Use the pre-fetched dictionary for parent lookup. + Floor? parentFloor = floors.TryGetValue(dto.FloorId, out var f) ? f : null; + var parentBuilding = parentFloor?.Building; + + bool isNew = dto.Id == null; + if (isNew) + { + _context.WorkAreas.Add(workArea); + messages.Add($"Work Area Added in Building: {parentBuilding?.Name ?? "Unknown"}, on Floor: {parentFloor?.FloorName ?? "Unknown"}"); + cacheTasks.Add(_cache.AddBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, workArea: workArea, buildingId: parentBuilding?.Id)); + } + else + { + _context.WorkAreas.Update(workArea); + messages.Add($"Work Area Updated in Building: {parentBuilding?.Name ?? "Unknown"}, on Floor: {parentFloor?.FloorName ?? "Unknown"}"); + cacheTasks.Add(_cache.UpdateBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, workArea: workArea, buildingId: parentBuilding?.Id)); + } + + responseData.workArea = workArea; + if (parentBuilding != null) projectIds.Add(parentBuilding.ProjectId); + } + #endregion } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index 2db004d..f1c89cc 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -21,6 +21,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task>> AssigneProjectsToEmployeeAsync(List projectAllocationDtos, Guid employeeId, Guid tenantId, Employee loggedInEmployee); Task> GetInfraDetailsAsync(Guid projectId, Guid tenantId, Employee loggedInEmployee); Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee); + Task ManageProjectInfraAsync(List infraDtos, Guid tenantId, Employee loggedInEmployee); Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee); } -- 2.43.0 From 089ae7e9e563b41aa6fd8887ade1c33859fed4cc Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Wed, 16 Jul 2025 18:39:29 +0530 Subject: [PATCH 17/50] Optimization of WorkItem Delete API in Project Controller --- .../Controllers/ProjectController.cs | 61 +-- Marco.Pms.Services/Service/ProjectServices.cs | 391 ++++-------------- .../ServiceInterfaces/IProjectServices.cs | 1 + 3 files changed, 90 insertions(+), 363 deletions(-) diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 71ef1a5..362c2af 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,7 +1,6 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; -using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service; @@ -11,7 +10,6 @@ using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.CodeAnalysis; -using Microsoft.EntityFrameworkCore; using MongoDB.Driver; namespace MarcoBMS.Services.Controllers @@ -410,55 +408,24 @@ namespace MarcoBMS.Services.Controllers [HttpDelete("task/{id}")] public async Task DeleteProjectTask(Guid id) { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - List workAreaIds = new List(); - WorkItem? task = await _context.WorkItems.AsNoTracking().Include(t => t.WorkArea).FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); - if (task != null) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - if (task.CompletedWork == 0) - { - var assignedTask = await _context.TaskAllocations.Where(t => t.WorkItemId == id).ToListAsync(); - if (assignedTask.Count == 0) - { - _context.WorkItems.Remove(task); - await _context.SaveChangesAsync(); - _logger.LogInfo("Task with ID {WorkItemId} has been successfully deleted.", task.Id); - - var floorId = task.WorkArea?.FloorId; - var floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == floorId); - - - workAreaIds.Add(task.WorkAreaId); - var projectId = floor?.Building?.ProjectId; - - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = $"Task Deleted in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}, in Area: {task.WorkArea?.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}" }; - await _signalR.SendNotificationAsync(notification); - await _cache.DeleteWorkItemByIdAsync(task.Id); - if (projectId != null) - { - await _cache.DeleteProjectByIdAsync(projectId.Value); - } - } - else - { - _logger.LogWarning("Task with ID {WorkItemId} is currently assigned and cannot be deleted.", task.Id); - return BadRequest(ApiResponse.ErrorResponse("Task is currently assigned and cannot be deleted.", "Task is currently assigned and cannot be deleted.", 400)); - } - } - else - { - double percentage = (task.CompletedWork / task.PlannedWork) * 100; - percentage = Math.Round(percentage, 2); - _logger.LogWarning("Task with ID {WorkItemId} is {CompletionPercentage}% complete and cannot be deleted", task.Id, percentage); - return BadRequest(ApiResponse.ErrorResponse(System.String.Format("Task is {0}% complete and cannot be deleted", percentage), System.String.Format("Task is {0}% complete and cannot be deleted", percentage), 400)); - - } + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - else + + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var serviceResponse = await _projectServices.DeleteProjectTaskAsync(id, tenantId, loggedInEmployee); + var response = serviceResponse.Response; + var notification = serviceResponse.Notification; + if (notification != null) { - _logger.LogWarning("Task with ID {WorkItemId} not found ID database", id); + await _signalR.SendNotificationAsync(notification); } - return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); + return StatusCode(response.StatusCode, response); } #endregion diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 32e1285..d7ab2ac 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -1033,130 +1033,6 @@ namespace Marco.Pms.Services.Service #region =================================================================== Project Infrastructre Manage APIs =================================================================== - public async Task> ManageProjectInfra(List infraDots, Guid tenantId, Employee loggedInEmployee) - { - var responseData = new InfraVM { }; - string responseMessage = ""; - string message = ""; - List projectIds = new List(); - if (infraDots != null) - { - foreach (var item in infraDots) - { - if (item.Building != null) - { - - Building building = _mapper.Map(item.Building); - building.TenantId = tenantId; - - if (item.Building.Id == null) - { - //create - _context.Buildings.Add(building); - await _context.SaveChangesAsync(); - responseData.building = building; - responseMessage = "Buliding Added Successfully"; - message = "Building Added"; - await _cache.AddBuildngInfra(building.ProjectId, building); - } - else - { - //update - _context.Buildings.Update(building); - await _context.SaveChangesAsync(); - responseData.building = building; - responseMessage = "Buliding Updated Successfully"; - message = "Building Updated"; - await _cache.UpdateBuildngInfra(building.ProjectId, building); - } - projectIds.Add(building.ProjectId); - } - if (item.Floor != null) - { - Floor floor = _mapper.Map(item.Floor); - floor.TenantId = tenantId; - bool isCreated = false; - - if (item.Floor.Id == null) - { - //create - _context.Floor.Add(floor); - await _context.SaveChangesAsync(); - responseData.floor = floor; - responseMessage = "Floor Added Successfully"; - message = "Floor Added"; - isCreated = true; - } - else - { - //update - _context.Floor.Update(floor); - await _context.SaveChangesAsync(); - responseData.floor = floor; - responseMessage = "Floor Updated Successfully"; - message = "Floor Updated"; - } - Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); - var projectId = building?.ProjectId ?? Guid.Empty; - projectIds.Add(projectId); - message = $"{message} in Building: {building?.Name}"; - if (isCreated) - { - await _cache.AddBuildngInfra(projectId, floor: floor); - } - else - { - await _cache.UpdateBuildngInfra(projectId, floor: floor); - } - } - if (item.WorkArea != null) - { - WorkArea workArea = _mapper.Map(item.WorkArea); - workArea.TenantId = tenantId; - bool isCreated = false; - - if (item.WorkArea.Id == null) - { - //create - _context.WorkAreas.Add(workArea); - await _context.SaveChangesAsync(); - responseData.workArea = workArea; - responseMessage = "Work Area Added Successfully"; - message = "Work Area Added"; - isCreated = true; - } - else - { - //update - _context.WorkAreas.Update(workArea); - await _context.SaveChangesAsync(); - responseData.workArea = workArea; - responseMessage = "Work Area Updated Successfully"; - message = "Work Area Updated"; - } - Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); - var projectId = floor?.Building?.ProjectId ?? Guid.Empty; - projectIds.Add(projectId); - message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; - if (isCreated) - { - await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - } - else - { - await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - } - } - } - message = $"{message} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; - var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; - - return ApiResponse.SuccessResponse(responseData, responseMessage, 200); - } - return ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400); - - } - public async Task ManageProjectInfraAsync(List infraDtos, Guid tenantId, Employee loggedInEmployee) { // 1. Guard Clause: Handle null or empty input gracefully. @@ -1244,151 +1120,6 @@ namespace Marco.Pms.Services.Service }; } - /// - /// Manages a batch of infrastructure changes (creates/updates for Buildings, Floors, and WorkAreas). - /// This method is optimized to perform all database operations in a single, atomic transaction. - /// - public async Task> ManageProjectInfraAsync1(List infraDtos, Guid tenantId, Employee loggedInEmployee) - { - // --- Step 1: Input Validation --- - if (infraDtos == null || !infraDtos.Any()) - { - _logger.LogWarning("ManageProjectInfraAsync called with null or empty DTO list."); - return ApiResponse.ErrorResponse("Invalid details.", "Infrastructure data cannot be empty.", 400); - } - - _logger.LogInfo("Begin ManageProjectInfraAsync for {DtoCount} items, TenantId: {TenantId}, User: {UserId}", infraDtos.Count, tenantId, loggedInEmployee.Id); - - // --- Step 2: Categorize DTOs by Type and Action --- - var buildingsToCreateDto = infraDtos.Where(i => i.Building != null && i.Building.Id == null).Select(i => i.Building!).ToList(); - var buildingsToUpdateDto = infraDtos.Where(i => i.Building != null && i.Building.Id != null).Select(i => i.Building!).ToList(); - var floorsToCreateDto = infraDtos.Where(i => i.Floor != null && i.Floor.Id == null).Select(i => i.Floor!).ToList(); - var floorsToUpdateDto = infraDtos.Where(i => i.Floor != null && i.Floor.Id != null).Select(i => i.Floor!).ToList(); - var workAreasToCreateDto = infraDtos.Where(i => i.WorkArea != null && i.WorkArea.Id == null).Select(i => i.WorkArea!).ToList(); - var workAreasToUpdateDto = infraDtos.Where(i => i.WorkArea != null && i.WorkArea.Id != null).Select(i => i.WorkArea!).ToList(); - - _logger.LogDebug("Categorized DTOs..."); - - try - { - // --- Step 3: Fetch all required existing data in bulk --- - - // Fetch existing entities to be updated - var buildingIdsToUpdate = buildingsToUpdateDto.Select(d => d.Id!.Value).ToList(); - var existingBuildings = await _context.Buildings.Where(b => buildingIdsToUpdate.Contains(b.Id) && b.TenantId == tenantId).ToDictionaryAsync(b => b.Id); - - var floorIdsToUpdate = floorsToUpdateDto.Select(d => d.Id!.Value).ToList(); - var existingFloors = await _context.Floor.Include(f => f.Building).Where(f => floorIdsToUpdate.Contains(f.Id) && f.TenantId == tenantId).ToDictionaryAsync(f => f.Id); - - var workAreaIdsToUpdate = workAreasToUpdateDto.Select(d => d.Id!.Value).ToList(); - var existingWorkAreas = await _context.WorkAreas.Include(wa => wa.Floor!.Building).Where(wa => workAreaIdsToUpdate.Contains(wa.Id) && wa.TenantId == tenantId).ToDictionaryAsync(wa => wa.Id); - - // Fetch parent entities for items being created to get their ProjectIds - var buildingIdsForNewFloors = floorsToCreateDto.Select(f => f.BuildingId).ToList(); - var parentBuildingsForNewFloors = await _context.Buildings.Where(b => buildingIdsForNewFloors.Contains(b.Id)).ToDictionaryAsync(b => b.Id); - - var floorIdsForNewWorkAreas = workAreasToCreateDto.Select(wa => wa.FloorId).ToList(); - var parentFloorsForNewWorkAreas = await _context.Floor.Include(f => f.Building).Where(f => floorIdsForNewWorkAreas.Contains(f.Id)).ToDictionaryAsync(f => f.Id); - - _logger.LogInfo("Fetched existing entities and parents for new items."); - - // --- Step 4: Aggregate all affected ProjectIds for Security Check --- - var affectedProjectIds = new HashSet(); - - // From buildings being created/updated - buildingsToCreateDto.ForEach(b => affectedProjectIds.Add(b.ProjectId)); - foreach (var b in existingBuildings.Values) { affectedProjectIds.Add(b.ProjectId); } - - // From floors being created/updated - foreach (var f in floorsToCreateDto) { if (parentBuildingsForNewFloors.TryGetValue(f.BuildingId, out var b)) affectedProjectIds.Add(b.ProjectId); } - foreach (var f in existingFloors.Values) { if (f.Building != null) affectedProjectIds.Add(f.Building.ProjectId); } - - // From work areas being created/updated - foreach (var wa in workAreasToCreateDto) { if (parentFloorsForNewWorkAreas.TryGetValue(wa.FloorId, out var f) && f.Building != null) affectedProjectIds.Add(f.Building.ProjectId); } - foreach (var wa in existingWorkAreas.Values) { if (wa.Floor?.Building != null) affectedProjectIds.Add(wa.Floor.Building.ProjectId); } - - // Security Check against the complete list of affected projects - var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProjectInfra, loggedInEmployee.Id); - if (!hasPermission) - { - _logger.LogWarning("Access DENIED for user {UserId} trying to manage infrastructure for projects.", loggedInEmployee.Id); - return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to manage infrastructure for one or more of the specified projects.", 403); - } - - // --- Step 5: Process all logic IN MEMORY, tracking changes --- - - // Process Buildings - var createdBuildings = new List(); - foreach (var dto in buildingsToCreateDto) - { - var newBuilding = _mapper.Map(dto); - newBuilding.TenantId = tenantId; - createdBuildings.Add(newBuilding); - } - foreach (var dto in buildingsToUpdateDto) { if (existingBuildings.TryGetValue(dto.Id!.Value, out var b)) _mapper.Map(dto, b); } - - // Process Floors - var createdFloors = new List(); - foreach (var dto in floorsToCreateDto) - { - var newFloor = _mapper.Map(dto); - newFloor.TenantId = tenantId; - createdFloors.Add(newFloor); - } - foreach (var dto in floorsToUpdateDto) { if (existingFloors.TryGetValue(dto.Id!.Value, out var f)) _mapper.Map(dto, f); } - - // Process WorkAreas - var createdWorkAreas = new List(); - foreach (var dto in workAreasToCreateDto) - { - var newWorkArea = _mapper.Map(dto); - newWorkArea.TenantId = tenantId; - createdWorkAreas.Add(newWorkArea); - } - foreach (var dto in workAreasToUpdateDto) { if (existingWorkAreas.TryGetValue(dto.Id!.Value, out var wa)) _mapper.Map(dto, wa); } - - // --- Step 6: Save all database changes in a SINGLE TRANSACTION --- - if (createdBuildings.Any()) _context.Buildings.AddRange(createdBuildings); - if (createdFloors.Any()) _context.Floor.AddRange(createdFloors); - if (createdWorkAreas.Any()) _context.WorkAreas.AddRange(createdWorkAreas); - - if (_context.ChangeTracker.HasChanges()) - { - await _context.SaveChangesAsync(); - _logger.LogInfo("Database save successful."); - } - - // --- Step 7: Update Cache using the aggregated ProjectIds (Non-blocking) --- - var finalProjectIds = affectedProjectIds.ToList(); - if (finalProjectIds.Any()) - { - _ = Task.Run(async () => - { - try - { - _logger.LogInfo("Queuing background cache update for {ProjectCount} projects.", finalProjectIds.Count); - // Assuming your cache service has a method to handle this. - await _cache.RemoveProjectsAsync(finalProjectIds); - _logger.LogInfo("Background cache update task completed for projects: {ProjectIds}", string.Join(", ", finalProjectIds)); - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred during the background cache update task for projects: {ProjectIds}", string.Join(", ", finalProjectIds)); - } - }); - } - - // --- Step 8: Prepare and return a clear response --- - var responseVm = new { /* ... as before ... */ }; - return ApiResponse.SuccessResponse(responseVm, "Infrastructure changes processed successfully.", 200); - } - catch (Exception ex) - { - _logger.LogError(ex, "An unexpected error occurred in ManageProjectInfraAsync."); - return ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500); - } - } - /// /// Creates or updates a batch of work items. /// This method is optimized to perform all database operations in a single, atomic transaction. @@ -1512,60 +1243,88 @@ namespace Marco.Pms.Services.Service return ApiResponse>.SuccessResponse(responseList, message, 200); } + public async Task DeleteProjectTaskAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + // 1. Fetch the task and its parent data in a single query. + // This is still a major optimization, avoiding a separate query for the floor/building. + WorkItem? task = await _context.WorkItems + .AsNoTracking() // Use AsNoTracking because we will re-attach for deletion later. + .Include(t => t.WorkArea) + .ThenInclude(wa => wa!.Floor) + .ThenInclude(f => f!.Building) + .FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); - //public async Task DeleteProjectTask(Guid id) - //{ - // var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - // List workAreaIds = new List(); - // WorkItem? task = await _context.WorkItems.AsNoTracking().Include(t => t.WorkArea).FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); - // if (task != null) - // { - // if (task.CompletedWork == 0) - // { - // var assignedTask = await _context.TaskAllocations.Where(t => t.WorkItemId == id).ToListAsync(); - // if (assignedTask.Count == 0) - // { - // _context.WorkItems.Remove(task); - // await _context.SaveChangesAsync(); - // _logger.LogInfo("Task with ID {WorkItemId} has been successfully deleted.", task.Id); + // 2. Guard Clause: Handle non-existent task. + if (task == null) + { + _logger.LogWarning("Attempted to delete a non-existent task with ID {WorkItemId}", id); + return new ServiceResponse + { + Response = ApiResponse.ErrorResponse("Task not found.", $"A task with ID {id} was not found.", 404) + }; + } - // var floorId = task.WorkArea?.FloorId; - // var floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == floorId); + // 3. Guard Clause: Prevent deletion if work has started. + if (task.CompletedWork > 0) + { + double percentage = Math.Round((task.CompletedWork / task.PlannedWork) * 100, 2); + _logger.LogWarning("Task with ID {WorkItemId} is {CompletionPercentage}% complete and cannot be deleted.", task.Id, percentage); + return new ServiceResponse + { + Response = ApiResponse.ErrorResponse($"Task is {percentage}% complete and cannot be deleted.", "Deletion failed because the task has progress.", 400) + }; + } + // 4. Guard Clause: Efficiently check if the task is assigned in a separate, optimized query. + // AnyAsync() is highly efficient and translates to a `SELECT TOP 1` or `EXISTS` in SQL. + bool isAssigned = await _context.TaskAllocations.AnyAsync(t => t.WorkItemId == id); + if (isAssigned) + { + _logger.LogWarning("Task with ID {WorkItemId} is currently assigned and cannot be deleted.", task.Id); + return new ServiceResponse + { + Response = ApiResponse.ErrorResponse("Task is currently assigned and cannot be deleted.", "Deletion failed because the task is assigned to an employee.", 400) + }; + } - // workAreaIds.Add(task.WorkAreaId); - // var projectId = floor?.Building?.ProjectId; + // --- Success Path: All checks passed, proceed with deletion --- - // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = $"Task Deleted in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}, in Area: {task.WorkArea?.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}" }; - // await _signalR.SendNotificationAsync(notification); - // await _cache.DeleteWorkItemByIdAsync(task.Id); - // if (projectId != null) - // { - // await _cache.DeleteProjectByIdAsync(projectId.Value); - // } - // } - // else - // { - // _logger.LogWarning("Task with ID {WorkItemId} is currently assigned and cannot be deleted.", task.Id); - // return BadRequest(ApiResponse.ErrorResponse("Task is currently assigned and cannot be deleted.", "Task is currently assigned and cannot be deleted.", 400)); - // } - // } - // else - // { - // double percentage = (task.CompletedWork / task.PlannedWork) * 100; - // percentage = Math.Round(percentage, 2); - // _logger.LogWarning("Task with ID {WorkItemId} is {CompletionPercentage}% complete and cannot be deleted", task.Id, percentage); - // return BadRequest(ApiResponse.ErrorResponse(System.String.Format("Task is {0}% complete and cannot be deleted", percentage), System.String.Format("Task is {0}% complete and cannot be deleted", percentage), 400)); + var building = task.WorkArea?.Floor?.Building; + var notification = new + { + LoggedInUserId = loggedInEmployee.Id, + Keyword = "WorkItem", + WorkAreaIds = new[] { task.WorkAreaId }, + Message = $"Task Deleted in Building: {building?.Name ?? "N/A"}, on Floor: {task.WorkArea?.Floor?.FloorName ?? "N/A"}, in Area: {task.WorkArea?.AreaName ?? "N/A"} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}" + }; - // } - // } - // else - // { - // _logger.LogWarning("Task with ID {WorkItemId} not found ID database", id); - // } - // return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); - //} + // 5. Perform the database deletion. + // We must attach a new instance or the original one without AsNoTracking. + // Since we used AsNoTracking, we create a 'stub' entity for deletion. + // This is more efficient than re-querying. + _context.WorkItems.Remove(new WorkItem { Id = task.Id }); + await _context.SaveChangesAsync(); + _logger.LogInfo("Task with ID {WorkItemId} has been successfully deleted.", task.Id); + // 6. Perform cache operations concurrently. + var cacheTasks = new List + { + _cache.DeleteWorkItemByIdAsync(task.Id) + }; + + if (building?.ProjectId != null) + { + cacheTasks.Add(_cache.DeleteProjectByIdAsync(building.ProjectId)); + } + await Task.WhenAll(cacheTasks); + + // 7. Return the final success response. + return new ServiceResponse + { + Notification = notification, + Response = ApiResponse.SuccessResponse(new { id = task.Id }, "Task deleted successfully.", 200) + }; + } #endregion #region =================================================================== Helper Functions =================================================================== diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index f1c89cc..0c7c964 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -23,6 +23,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee); Task ManageProjectInfraAsync(List infraDtos, Guid tenantId, Employee loggedInEmployee); Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee); + Task DeleteProjectTaskAsync(Guid id, Guid tenantId, Employee loggedInEmployee); } } -- 2.43.0 From ccce0d48d5477a9432cac01221b68eff4d0f7bd2 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 10:17:57 +0530 Subject: [PATCH 18/50] Remove the projectHelper and ProjetsHelper and move its bussiness logic to project services --- Marco.Pms.CacheHelper/EmployeeCache.cs | 20 +++ Marco.Pms.CacheHelper/ProjectCache.cs | 74 ++++++++---- .../EmployeePermissionMongoDB.cs | 1 + .../MongoDBModels/ProjectMongoDB.cs | 1 + .../Controllers/AttendanceController.cs | 13 +- .../Controllers/EmployeeController.cs | 9 +- .../Controllers/UserController.cs | 11 +- Marco.Pms.Services/Helpers/ProjectHelper.cs | 37 ------ Marco.Pms.Services/Helpers/ProjectsHelper.cs | 81 ------------- Marco.Pms.Services/Program.cs | 1 - Marco.Pms.Services/Service/ProjectServices.cs | 114 ++++++++++++++++-- .../ServiceInterfaces/IProjectServices.cs | 6 + 12 files changed, 206 insertions(+), 162 deletions(-) delete mode 100644 Marco.Pms.Services/Helpers/ProjectHelper.cs delete mode 100644 Marco.Pms.Services/Helpers/ProjectsHelper.cs diff --git a/Marco.Pms.CacheHelper/EmployeeCache.cs b/Marco.Pms.CacheHelper/EmployeeCache.cs index f7b7066..0079106 100644 --- a/Marco.Pms.CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.CacheHelper/EmployeeCache.cs @@ -33,6 +33,8 @@ namespace Marco.Pms.CacheHelper var result = await _collection.UpdateOneAsync(filter, update, options); + await InitializeCollectionAsync(); + // 6. Return a more accurate result indicating success for both updates and upserts. // The operation is successful if an existing document was modified OR a new one was created. return result.IsAcknowledged && (result.ModifiedCount > 0 || result.UpsertedId != null); @@ -51,6 +53,7 @@ namespace Marco.Pms.CacheHelper { return false; } + await InitializeCollectionAsync(); return true; } public async Task> GetProjectsFromCache(Guid employeeId) @@ -177,5 +180,22 @@ namespace Marco.Pms.CacheHelper return true; } + + // A private method to handle the one-time setup of the collection's indexes. + private async Task InitializeCollectionAsync() + { + // 1. Define the TTL (Time-To-Live) index on the 'ExpireAt' field. + var indexKeys = Builders.IndexKeys.Ascending(x => x.ExpireAt); + var indexOptions = new CreateIndexOptions + { + // This tells MongoDB to automatically delete documents when their 'ExpireAt' time is reached. + ExpireAfter = TimeSpan.FromSeconds(0) + }; + var indexModel = new CreateIndexModel(indexKeys, indexOptions); + + // 2. Create the index. This is an idempotent operation if the index already exists. + // Use CreateOneAsync since we are only creating a single index. + await _collection.Indexes.CreateOneAsync(indexModel); + } } } diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index 9417724..df95419 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -11,27 +11,59 @@ namespace Marco.Pms.CacheHelper { public class ProjectCache { - private readonly ApplicationDbContext _context; - private readonly IMongoCollection _projetCollection; + private readonly IMongoCollection _projectCollection; private readonly IMongoCollection _taskCollection; public ProjectCache(ApplicationDbContext context, IConfiguration configuration) { var connectionString = configuration["MongoDB:ConnectionString"]; - _context = context; var mongoUrl = new MongoUrl(connectionString); var client = new MongoClient(mongoUrl); // Your MongoDB connection string var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name - _projetCollection = mongoDB.GetCollection("ProjectDetails"); + _projectCollection = mongoDB.GetCollection("ProjectDetails"); _taskCollection = mongoDB.GetCollection("WorkItemDetails"); } public async Task AddProjectDetailsToCache(ProjectMongoDB projectDetails) { - await _projetCollection.InsertOneAsync(projectDetails); + await _projectCollection.InsertOneAsync(projectDetails); + + var indexKeys = Builders.IndexKeys.Ascending(x => x.ExpireAt); + var indexOptions = new CreateIndexOptions + { + ExpireAfter = TimeSpan.Zero // required for fixed expiration time + }; + var indexModel = new CreateIndexModel(indexKeys, indexOptions); + await _projectCollection.Indexes.CreateOneAsync(indexModel); + } + // The method should focus only on inserting data. public async Task AddProjectDetailsListToCache(List projectDetailsList) { - await _projetCollection.InsertManyAsync(projectDetailsList); + // 1. Add a guard clause to avoid an unnecessary database call for an empty list. + if (projectDetailsList == null || !projectDetailsList.Any()) + { + return; + } + + // 2. Perform the insert operation. This is the only responsibility of this method. + await _projectCollection.InsertManyAsync(projectDetailsList); + await InitializeCollectionAsync(); + } + // A private method to handle the one-time setup of the collection's indexes. + private async Task InitializeCollectionAsync() + { + // 1. Define the TTL (Time-To-Live) index on the 'ExpireAt' field. + var indexKeys = Builders.IndexKeys.Ascending(x => x.ExpireAt); + var indexOptions = new CreateIndexOptions + { + // This tells MongoDB to automatically delete documents when their 'ExpireAt' time is reached. + ExpireAfter = TimeSpan.FromSeconds(0) + }; + var indexModel = new CreateIndexModel(indexKeys, indexOptions); + + // 2. Create the index. This is an idempotent operation if the index already exists. + // Use CreateOneAsync since we are only creating a single index. + await _projectCollection.Indexes.CreateOneAsync(indexModel); } public async Task UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus) { @@ -51,7 +83,7 @@ namespace Marco.Pms.CacheHelper ); // Perform the update - var result = await _projetCollection.UpdateOneAsync( + var result = await _projectCollection.UpdateOneAsync( filter: r => r.Id == project.Id.ToString(), update: updates ); @@ -71,7 +103,7 @@ namespace Marco.Pms.CacheHelper var projection = Builders.Projection.Exclude(p => p.Buildings); // Perform query - var project = await _projetCollection + var project = await _projectCollection .Find(filter) .Project(projection) .FirstOrDefaultAsync(); @@ -83,7 +115,7 @@ namespace Marco.Pms.CacheHelper List stringProjectIds = projectIds.Select(p => p.ToString()).ToList(); var filter = Builders.Filter.In(p => p.Id, stringProjectIds); var projection = Builders.Projection.Exclude(p => p.Buildings); - var projects = await _projetCollection + var projects = await _projectCollection .Find(filter) .Project(projection) .ToListAsync(); @@ -92,14 +124,14 @@ namespace Marco.Pms.CacheHelper public async Task DeleteProjectByIdFromCacheAsync(Guid projectId) { var filter = Builders.Filter.Eq(e => e.Id, projectId.ToString()); - var result = await _projetCollection.DeleteOneAsync(filter); + var result = await _projectCollection.DeleteOneAsync(filter); return result.DeletedCount > 0; } public async Task RemoveProjectsFromCacheAsync(List projectIds) { var stringIds = projectIds.Select(id => id.ToString()).ToList(); var filter = Builders.Filter.In(p => p.Id, stringIds); - var result = await _projetCollection.DeleteManyAsync(filter); + var result = await _projectCollection.DeleteManyAsync(filter); return result.DeletedCount > 0; } @@ -125,7 +157,7 @@ namespace Marco.Pms.CacheHelper var filter = Builders.Filter.Eq(p => p.Id, stringProjectId); var update = Builders.Update.Push("Buildings", buildingMongo); - var result = await _projetCollection.UpdateOneAsync(filter, update); + var result = await _projectCollection.UpdateOneAsync(filter, update); if (result.MatchedCount == 0) { @@ -155,7 +187,7 @@ namespace Marco.Pms.CacheHelper ); var update = Builders.Update.Push("Buildings.$.Floors", floorMongo); - var result = await _projetCollection.UpdateOneAsync(filter, update); + var result = await _projectCollection.UpdateOneAsync(filter, update); if (result.MatchedCount == 0) { @@ -189,7 +221,7 @@ namespace Marco.Pms.CacheHelper var update = Builders.Update.Push("Buildings.$[b].Floors.$[f].WorkAreas", workAreaMongo); var updateOptions = new UpdateOptions { ArrayFilters = arrayFilters }; - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); if (result.MatchedCount == 0) { @@ -221,7 +253,7 @@ namespace Marco.Pms.CacheHelper Builders.Update.Set("Buildings.$.Description", building.Description) ); - var result = await _projetCollection.UpdateOneAsync(filter, update); + var result = await _projectCollection.UpdateOneAsync(filter, update); if (result.MatchedCount == 0) { @@ -246,7 +278,7 @@ namespace Marco.Pms.CacheHelper var updateOptions = new UpdateOptions { ArrayFilters = arrayFilters }; var filter = Builders.Filter.Eq(p => p.Id, stringProjectId); - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); if (result.MatchedCount == 0) { @@ -272,7 +304,7 @@ namespace Marco.Pms.CacheHelper var updateOptions = new UpdateOptions { ArrayFilters = arrayFilters }; var filter = Builders.Filter.Eq(p => p.Id, stringProjectId); - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); if (result.MatchedCount == 0) { @@ -296,7 +328,7 @@ namespace Marco.Pms.CacheHelper var filter = Builders.Filter.Eq(p => p.Id, projectId.ToString()); // Project only the "Buildings" field from the document - var buildings = await _projetCollection + var buildings = await _projectCollection .Find(filter) .Project(p => p.Buildings) .FirstOrDefaultAsync(); @@ -315,7 +347,7 @@ namespace Marco.Pms.CacheHelper public async Task UpdatePlannedAndCompleteWorksInBuildingFromCache(Guid workAreaId, double plannedWork, double completedWork) { var filter = Builders.Filter.Eq("Buildings.Floors.WorkAreas._id", workAreaId.ToString()); - var project = await _projetCollection.Find(filter).FirstOrDefaultAsync(); + var project = await _projectCollection.Find(filter).FirstOrDefaultAsync(); string? selectedBuildingId = null; string? selectedFloorId = null; @@ -353,7 +385,7 @@ namespace Marco.Pms.CacheHelper .Inc("Buildings.$[b].CompletedWork", completedWork) .Inc("PlannedWork", plannedWork) .Inc("CompletedWork", completedWork); - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); } public async Task GetBuildingAndFloorByWorkAreaIdFromCache(Guid workAreaId) @@ -393,7 +425,7 @@ namespace Marco.Pms.CacheHelper { "WorkArea", "$Buildings.Floors.WorkAreas" } }) }; - var result = await _projetCollection.Aggregate(pipeline).FirstOrDefaultAsync(); + var result = await _projectCollection.Aggregate(pipeline).FirstOrDefaultAsync(); if (result == null) return null; return result; diff --git a/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs b/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs index 49c514e..fab2b84 100644 --- a/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs +++ b/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs @@ -9,5 +9,6 @@ namespace Marco.Pms.Model.MongoDBModels public List ApplicationRoleIds { get; set; } = new List(); public List PermissionIds { get; set; } = new List(); public List ProjectIds { get; set; } = new List(); + public DateTime ExpireAt { get; set; } = DateTime.UtcNow.Date.AddDays(1); } } diff --git a/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs b/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs index 7f3a557..aac0e2c 100644 --- a/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs +++ b/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs @@ -14,5 +14,6 @@ public int TeamSize { get; set; } public double CompletedWork { get; set; } public double PlannedWork { get; set; } + public DateTime ExpireAt { get; set; } = DateTime.UtcNow.Date.AddDays(1); } } diff --git a/Marco.Pms.Services/Controllers/AttendanceController.cs b/Marco.Pms.Services/Controllers/AttendanceController.cs index 1a5e4e7..7339966 100644 --- a/Marco.Pms.Services/Controllers/AttendanceController.cs +++ b/Marco.Pms.Services/Controllers/AttendanceController.cs @@ -9,6 +9,7 @@ using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.AttendanceVM; 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; @@ -28,7 +29,7 @@ namespace MarcoBMS.Services.Controllers { private readonly ApplicationDbContext _context; private readonly EmployeeHelper _employeeHelper; - private readonly ProjectsHelper _projectsHelper; + private readonly IProjectServices _projectServices; private readonly UserHelper _userHelper; private readonly S3UploadService _s3Service; private readonly PermissionServices _permission; @@ -37,11 +38,11 @@ namespace MarcoBMS.Services.Controllers public AttendanceController( - ApplicationDbContext context, EmployeeHelper employeeHelper, ProjectsHelper projectsHelper, UserHelper userHelper, S3UploadService s3Service, ILoggingService logger, PermissionServices permission, IHubContext signalR) + ApplicationDbContext context, EmployeeHelper employeeHelper, IProjectServices projectServices, UserHelper userHelper, S3UploadService s3Service, ILoggingService logger, PermissionServices permission, IHubContext signalR) { _context = context; _employeeHelper = employeeHelper; - _projectsHelper = projectsHelper; + _projectServices = projectServices; _userHelper = userHelper; _s3Service = s3Service; _logger = logger; @@ -188,7 +189,7 @@ namespace MarcoBMS.Services.Controllers List lstAttendance = await _context.Attendes.Where(c => c.ProjectID == projectId && c.AttendanceDate.Date >= fromDate.Date && c.AttendanceDate.Date <= toDate.Date && c.TenantId == TenantId).ToListAsync(); - List projectteam = await _projectsHelper.GetTeamByProject(TenantId, projectId, true); + List projectteam = await _projectServices.GetTeamByProject(TenantId, projectId, true); var jobRole = await _context.JobRoles.ToListAsync(); foreach (Attendance? attendance in lstAttendance) { @@ -295,7 +296,7 @@ namespace MarcoBMS.Services.Controllers List lstAttendance = await _context.Attendes.Where(c => c.ProjectID == projectId && c.AttendanceDate.Date == forDate && c.TenantId == TenantId).ToListAsync(); - List projectteam = await _projectsHelper.GetTeamByProject(TenantId, projectId, IncludeInActive); + List projectteam = await _projectServices.GetTeamByProject(TenantId, projectId, IncludeInActive); var idList = projectteam.Select(p => p.EmployeeId).ToList(); //var emp = await _context.Employees.Where(e => idList.Contains(e.Id)).Include(e => e.JobRole).ToListAsync(); var jobRole = await _context.JobRoles.ToListAsync(); @@ -378,7 +379,7 @@ namespace MarcoBMS.Services.Controllers List lstAttendance = await _context.Attendes.Where(c => c.ProjectID == projectId && c.Activity == ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE && c.TenantId == TenantId).ToListAsync(); - List projectteam = await _projectsHelper.GetTeamByProject(TenantId, projectId, true); + List projectteam = await _projectServices.GetTeamByProject(TenantId, projectId, true); var idList = projectteam.Select(p => p.EmployeeId).ToList(); var jobRole = await _context.JobRoles.ToListAsync(); diff --git a/Marco.Pms.Services/Controllers/EmployeeController.cs b/Marco.Pms.Services/Controllers/EmployeeController.cs index c9e19fa..d5d7f3d 100644 --- a/Marco.Pms.Services/Controllers/EmployeeController.cs +++ b/Marco.Pms.Services/Controllers/EmployeeController.cs @@ -9,6 +9,7 @@ using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Employee; 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; @@ -37,13 +38,13 @@ namespace MarcoBMS.Services.Controllers private readonly ILoggingService _logger; private readonly IHubContext _signalR; private readonly PermissionServices _permission; - private readonly ProjectsHelper _projectsHelper; + private readonly IProjectServices _projectServices; private readonly Guid tenantId; public EmployeeController(UserManager userManager, IEmailSender emailSender, ApplicationDbContext context, EmployeeHelper employeeHelper, UserHelper userHelper, IConfiguration configuration, ILoggingService logger, - IHubContext signalR, PermissionServices permission, ProjectsHelper projectsHelper) + IHubContext signalR, PermissionServices permission, IProjectServices projectServices) { _context = context; _userManager = userManager; @@ -54,7 +55,7 @@ namespace MarcoBMS.Services.Controllers _logger = logger; _signalR = signalR; _permission = permission; - _projectsHelper = projectsHelper; + _projectServices = projectServices; tenantId = _userHelper.GetTenantId(); } @@ -119,7 +120,7 @@ namespace MarcoBMS.Services.Controllers loggedInEmployee.Id, projectid ?? Guid.Empty, ShowInactive); // Step 3: Fetch project access and permissions - var projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + var projectIds = await _projectServices.GetMyProjectIdsAsync(tenantId, loggedInEmployee); var hasViewAllEmployeesPermission = await _permission.HasPermission(PermissionsMaster.ViewAllEmployees, loggedInEmployee.Id); var hasViewTeamMembersPermission = await _permission.HasPermission(PermissionsMaster.ViewTeamMembers, loggedInEmployee.Id); diff --git a/Marco.Pms.Services/Controllers/UserController.cs b/Marco.Pms.Services/Controllers/UserController.cs index 4bb4432..8269d3e 100644 --- a/Marco.Pms.Services/Controllers/UserController.cs +++ b/Marco.Pms.Services/Controllers/UserController.cs @@ -4,6 +4,7 @@ using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Employee; +using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,14 +20,14 @@ namespace MarcoBMS.Services.Controllers private readonly UserHelper _userHelper; private readonly EmployeeHelper _employeeHelper; - private readonly ProjectsHelper _projectsHelper; + private readonly IProjectServices _projectServices; private readonly RolesHelper _rolesHelper; - public UserController(EmployeeHelper employeeHelper, ProjectsHelper projectsHelper, UserHelper userHelper, RolesHelper rolesHelper) + public UserController(EmployeeHelper employeeHelper, IProjectServices projectServices, UserHelper userHelper, RolesHelper rolesHelper) { _userHelper = userHelper; _employeeHelper = employeeHelper; - _projectsHelper = projectsHelper; + _projectServices = projectServices; _rolesHelper = rolesHelper; } @@ -56,12 +57,12 @@ namespace MarcoBMS.Services.Controllers /* User with permission manage project can see all projects */ if (featurePermission != null && featurePermission.Exists(c => c.Id.ToString() == "172fc9b6-755b-4f62-ab26-55c34a330614")) { - List projects = await _projectsHelper.GetAllProjectByTanentID(emp.TenantId); + List projects = await _projectServices.GetAllProjectByTanentID(emp.TenantId); projectsId = projects.Select(c => c.Id.ToString()).ToArray(); } else { - List allocation = await _projectsHelper.GetProjectByEmployeeID(emp.Id); + List allocation = await _projectServices.GetProjectByEmployeeID(emp.Id); projectsId = allocation.Select(c => c.ProjectId.ToString()).ToArray(); } EmployeeProfile profile = new EmployeeProfile() { }; diff --git a/Marco.Pms.Services/Helpers/ProjectHelper.cs b/Marco.Pms.Services/Helpers/ProjectHelper.cs deleted file mode 100644 index f1b688e..0000000 --- a/Marco.Pms.Services/Helpers/ProjectHelper.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Projects; -using Microsoft.CodeAnalysis; -using Microsoft.EntityFrameworkCore; - - -namespace ModelServices.Helpers -{ - public class ProjectHelper - { - private readonly ApplicationDbContext _context; - public ProjectHelper(ApplicationDbContext context) - { - _context = context; - } - - public async Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive) - { - if (IncludeInactive) - { - - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId).Include(e => e.Employee).ToListAsync(); - - return employees; - } - else - { - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId && c.IsActive == true).Include(e => e.Employee).ToListAsync(); - - return employees; - } - } - - - - } -} diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs deleted file mode 100644 index e7e1dd6..0000000 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Employees; -using Marco.Pms.Model.Entitlements; -using Marco.Pms.Model.Projects; -using Marco.Pms.Services.Helpers; -using Marco.Pms.Services.Service; -using Microsoft.EntityFrameworkCore; - -namespace MarcoBMS.Services.Helpers -{ - public class ProjectsHelper - { - private readonly ApplicationDbContext _context; - private readonly CacheUpdateHelper _cache; - private readonly PermissionServices _permission; - - public ProjectsHelper(ApplicationDbContext context, CacheUpdateHelper cache, PermissionServices permission) - { - _context = context; - _cache = cache; - _permission = permission; - } - - public async Task> GetAllProjectByTanentID(Guid tanentID) - { - List alloc = await _context.Projects.Where(c => c.TenantId == tanentID).ToListAsync(); - return alloc; - } - - public async Task> GetProjectByEmployeeID(Guid employeeID) - { - List alloc = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeID && c.IsActive == true).Include(c => c.Project).ToListAsync(); - return alloc; - } - - public async Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive) - { - if (IncludeInactive) - { - - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId).Include(e => e.Employee).ToListAsync(); - - return employees; - } - else - { - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId && c.IsActive == true).Include(e => e.Employee).ToListAsync(); - - return employees; - } - } - - public async Task> GetMyProjects(Guid tenantId, Employee LoggedInEmployee) - { - var projectIds = await _cache.GetProjects(LoggedInEmployee.Id); - - if (projectIds == null) - { - var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, LoggedInEmployee.Id); - if (hasPermission) - { - var projects = await _context.Projects.Where(c => c.TenantId == tenantId).ToListAsync(); - projectIds = projects.Select(p => p.Id).ToList(); - } - else - { - var allocation = await GetProjectByEmployeeID(LoggedInEmployee.Id); - if (!allocation.Any()) - { - return new List(); - } - projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); - } - await _cache.AddProjects(LoggedInEmployee.Id, projectIds); - } - - return projectIds; - } - - } -} \ No newline at end of file diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 3c73416..3f012e2 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -167,7 +167,6 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index d7ab2ac..9406ec9 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -12,7 +12,6 @@ using Marco.Pms.Model.ViewModels.Employee; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service.ServiceInterfaces; -using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; @@ -25,7 +24,6 @@ namespace Marco.Pms.Services.Service private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate private readonly ILoggingService _logger; - private readonly ProjectsHelper _projectsHelper; private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; private readonly IMapper _mapper; @@ -34,7 +32,6 @@ namespace Marco.Pms.Services.Service IDbContextFactory dbContextFactory, ApplicationDbContext context, ILoggingService logger, - ProjectsHelper projectsHelper, PermissionServices permission, CacheUpdateHelper cache, IMapper mapper, @@ -43,7 +40,6 @@ namespace Marco.Pms.Services.Service _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _projectsHelper = projectsHelper ?? throw new ArgumentNullException(nameof(projectsHelper)); _permission = permission ?? throw new ArgumentNullException(nameof(permission)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); @@ -64,7 +60,7 @@ namespace Marco.Pms.Services.Service _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); // Step 2: Get the list of project IDs the user has access to - List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + List accessibleProjectIds = await GetMyProjects(tenantId, loggedInEmployee); if (accessibleProjectIds == null || !accessibleProjectIds.Any()) { @@ -94,7 +90,7 @@ namespace Marco.Pms.Services.Service _logger.LogInfo("Starting GetAllProjects for TenantId: {TenantId}, User: {UserId}", tenantId, loggedInEmployee.Id); // --- Step 1: Get a list of project IDs the user can access --- - List projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + List projectIds = await GetMyProjects(tenantId, loggedInEmployee); if (!projectIds.Any()) { _logger.LogInfo("User has no assigned projects. Returning empty list."); @@ -743,7 +739,7 @@ namespace Marco.Pms.Services.Service // This is a placeholder for your actual, more specific permission logic. // It should also handle the case where a user is requesting their own projects (employeeId == loggedInEmployee.Id). var hasPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); - var projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + var projectIds = await GetMyProjects(tenantId, loggedInEmployee); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} trying to view projects for employee {TargetEmployeeId}.", loggedInEmployee.Id, employeeId); @@ -1329,6 +1325,110 @@ namespace Marco.Pms.Services.Service #region =================================================================== Helper Functions =================================================================== + public async Task> GetAllProjectByTanentID(Guid tanentId) + { + List alloc = await _context.Projects.Where(c => c.TenantId == tanentId).ToListAsync(); + return alloc; + } + + public async Task> GetProjectByEmployeeID(Guid employeeId) + { + List alloc = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive == true).Include(c => c.Project).ToListAsync(); + return alloc; + } + + public async Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive) + { + if (IncludeInactive) + { + + var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId).Include(e => e.Employee).ToListAsync(); + + return employees; + } + else + { + var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId && c.IsActive == true).Include(e => e.Employee).ToListAsync(); + + return employees; + } + } + + public async Task> GetMyProjects(Guid tenantId, Employee LoggedInEmployee) + { + var projectIds = await _cache.GetProjects(LoggedInEmployee.Id); + + if (projectIds == null) + { + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, LoggedInEmployee.Id); + if (hasPermission) + { + var projects = await _context.Projects.Where(c => c.TenantId == tenantId).ToListAsync(); + projectIds = projects.Select(p => p.Id).ToList(); + } + else + { + var allocation = await GetProjectByEmployeeID(LoggedInEmployee.Id); + if (!allocation.Any()) + { + return new List(); + } + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); + } + await _cache.AddProjects(LoggedInEmployee.Id, projectIds); + } + return projectIds; + } + + public async Task> GetMyProjectIdsAsync(Guid tenantId, Employee loggedInEmployee) + { + // 1. Attempt to retrieve the list of project IDs from the cache first. + // This is the "happy path" and should be as fast as possible. + List? projectIds = await _cache.GetProjects(loggedInEmployee.Id); + + if (projectIds != null) + { + // Cache Hit: Return the cached list immediately. + return projectIds; + } + + // 2. Cache Miss: The list was not in the cache, so we must fetch it from the database. + List newProjectIds; + + // Check for the specific permission. + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, loggedInEmployee.Id); + + if (hasPermission) + { + // 3a. OPTIMIZATION: User has permission to see all projects. + // Fetch *only* the Ids directly from the database. This is far more efficient + // than fetching full Project objects and then selecting the Ids in memory. + newProjectIds = await _context.Projects + .Where(p => p.TenantId == tenantId) + .Select(p => p.Id) // This translates to `SELECT Id FROM Projects...` in SQL. + .ToListAsync(); + } + else + { + // 3b. OPTIMIZATION: User can only see projects they are allocated to. + // We go directly to the source (ProjectAllocations) and ask the database + // for a distinct list of ProjectIds. This is much better than calling a + // helper function that might return full allocation objects. + newProjectIds = await _context.ProjectAllocations + .Where(a => a.EmployeeId == loggedInEmployee.Id && a.ProjectId != Guid.Empty) + .Select(a => a.ProjectId) + .Distinct() // Pushes the DISTINCT operation to the database. + .ToListAsync(); + } + + // 4. Populate the cache with the newly fetched list (even if it's empty). + // This prevents repeated database queries for employees with no projects. + await _cache.AddProjects(loggedInEmployee.Id, newProjectIds); + + return newProjectIds; + } + + /// /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index 0c7c964..b5acccc 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -1,5 +1,6 @@ using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Projects; @@ -25,5 +26,10 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee); Task DeleteProjectTaskAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + Task> GetAllProjectByTanentID(Guid tanentId); + Task> GetProjectByEmployeeID(Guid employeeId); + Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive); + Task> GetMyProjectIdsAsync(Guid tenantId, Employee LoggedInEmployee); + } } -- 2.43.0 From 2a3c75b0c83ae3190bb7af2c61b555143124518c Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 12:42:02 +0530 Subject: [PATCH 19/50] Removed the reassgining of same object --- Marco.Pms.Services/Helpers/DirectoryHelper.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Marco.Pms.Services/Helpers/DirectoryHelper.cs b/Marco.Pms.Services/Helpers/DirectoryHelper.cs index 33460b2..c762d70 100644 --- a/Marco.Pms.Services/Helpers/DirectoryHelper.cs +++ b/Marco.Pms.Services/Helpers/DirectoryHelper.cs @@ -1157,11 +1157,12 @@ namespace Marco.Pms.Services.Helpers List employeeBuckets = await _context.EmployeeBucketMappings.Where(b => b.EmployeeId == LoggedInEmployee.Id).ToListAsync(); var bucketIds = employeeBuckets.Select(b => b.BucketId).ToList(); - List employeeBucketVM = await _context.EmployeeBucketMappings.Where(b => bucketIds.Contains(b.BucketId)).ToListAsync(); + List bucketList = new List(); if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin)) { bucketList = await _context.Buckets.Include(b => b.CreatedBy).Where(b => b.TenantId == tenantId).ToListAsync(); + bucketIds = bucketList.Select(b => b.Id).ToList(); } else if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin) || permissionIds.Contains(PermissionsMaster.DirectoryUser)) { @@ -1173,6 +1174,8 @@ namespace Marco.Pms.Services.Helpers return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } + List employeeBucketVM = await _context.EmployeeBucketMappings.Where(b => bucketIds.Contains(b.BucketId)).ToListAsync(); + List bucketVMs = new List(); if (bucketList.Any()) { -- 2.43.0 From 760b4638e607856e132064aa093c5c479580babe Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 12:53:01 +0530 Subject: [PATCH 20/50] Added one more condition to check if active is false while removing the employee from buckets --- Marco.Pms.Services/Helpers/DirectoryHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Marco.Pms.Services/Helpers/DirectoryHelper.cs b/Marco.Pms.Services/Helpers/DirectoryHelper.cs index c762d70..ff66868 100644 --- a/Marco.Pms.Services/Helpers/DirectoryHelper.cs +++ b/Marco.Pms.Services/Helpers/DirectoryHelper.cs @@ -1369,7 +1369,7 @@ namespace Marco.Pms.Services.Helpers _context.EmployeeBucketMappings.Add(employeeBucketMapping); assignedEmployee += 1; } - else + else if (!assignBucket.IsActive) { EmployeeBucketMapping? employeeBucketMapping = employeeBuckets.FirstOrDefault(eb => eb.BucketId == bucketId && eb.EmployeeId == assignBucket.EmployeeId); if (employeeBucketMapping != null) -- 2.43.0 From c6ba233e6de20c800bed9342b39bc44e52713658 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 16:06:54 +0530 Subject: [PATCH 21/50] Added the logs setp in program.cs --- Marco.Pms.Services/Program.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 3f012e2..5549702 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -23,9 +23,21 @@ var builder = WebApplication.CreateBuilder(args); #region ======================= Service Configuration (Dependency Injection) ======================= #region Logging + +// Add Serilog Configuration +string? mongoConn = builder.Configuration["MongoDB:SerilogDatabaseUrl"]; +string timeString = "00:00:30"; +TimeSpan.TryParse(timeString, out TimeSpan timeSpan); + builder.Host.UseSerilog((context, config) => { - config.ReadFrom.Configuration(context.Configuration); + config.ReadFrom.Configuration(context.Configuration) + .WriteTo.MongoDB( + databaseUrl: mongoConn ?? string.Empty, + collectionName: "api-logs", + batchPostingLimit: 100, + period: timeSpan + ); }); #endregion -- 2.43.0 From 5b0e9ffb7c51da565562dd280f1a0ce7c1435a66 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 16:38:39 +0530 Subject: [PATCH 22/50] added new function delete all employee entries from cache --- Marco.Pms.CacheHelper/EmployeeCache.cs | 21 +++++++++++-------- .../Helpers/CacheUpdateHelper.cs | 11 ++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/Marco.Pms.CacheHelper/EmployeeCache.cs b/Marco.Pms.CacheHelper/EmployeeCache.cs index 0079106..7c7f4b4 100644 --- a/Marco.Pms.CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.CacheHelper/EmployeeCache.cs @@ -122,16 +122,10 @@ namespace Marco.Pms.CacheHelper public async Task ClearAllProjectIdsByPermissionIdFromCache(Guid permissionId) { var filter = Builders.Filter.AnyEq(e => e.PermissionIds, permissionId.ToString()); + var update = Builders.Update.Set(e => e.ProjectIds, new List()); - var update = Builders.Update - .Set(e => e.ProjectIds, new List()); - - var result = await _collection.UpdateOneAsync(filter, update); - - if (result.MatchedCount == 0) - return false; - - return true; + var result = await _collection.UpdateManyAsync(filter, update).ConfigureAwait(false); + return result.IsAcknowledged && result.ModifiedCount > 0; } public async Task RemoveRoleIdFromCache(Guid employeeId, Guid roleId) { @@ -180,6 +174,15 @@ namespace Marco.Pms.CacheHelper return true; } + public async Task ClearAllEmployeesFromCache() + { + var result = await _collection.DeleteManyAsync(FilterDefinition.Empty); + + if (result.DeletedCount == 0) + return false; + + return true; + } // A private method to handle the one-time setup of the collection's indexes. private async Task InitializeCollectionAsync() diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index b0b1e06..9bb159b 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -811,6 +811,17 @@ namespace Marco.Pms.Services.Helpers _logger.LogWarning("Error occured while deleting Application role {RoleId} from Cache for employee {EmployeeId}: {Error}", roleId, employeeId, ex.Message); } } + public async Task ClearAllEmployees() + { + try + { + var response = await _employeeCache.ClearAllEmployeesFromCache(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while deleting all employees from Cache"); + } + } // ------------------------------------ Report Cache --------------------------------------- -- 2.43.0 From 079a3804229f6e4e4f18880ddce5d26f4fd9c847 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 17:01:51 +0530 Subject: [PATCH 23/50] Deleted the unused variable --- .../Controllers/ProjectController.cs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 362c2af..796fd39 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,9 +1,6 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Utilities; -using Marco.Pms.Services.Helpers; -using Marco.Pms.Services.Service; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; @@ -20,30 +17,21 @@ namespace MarcoBMS.Services.Controllers public class ProjectController : ControllerBase { private readonly IProjectServices _projectServices; - private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly ILoggingService _logger; private readonly ISignalRService _signalR; - private readonly PermissionServices _permission; - private readonly CacheUpdateHelper _cache; private readonly Guid tenantId; public ProjectController( - ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, ISignalRService signalR, - CacheUpdateHelper cache, - PermissionServices permission, IProjectServices projectServices) { - _context = context; _userHelper = userHelper; _logger = logger; _signalR = signalR; - _cache = cache; - _permission = permission; _projectServices = projectServices; tenantId = userHelper.GetTenantId(); } -- 2.43.0 From 5e84ee9345c8a6db143cd4b456604bc9374e86ee Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 17:16:16 +0530 Subject: [PATCH 24/50] Removed commented code from project Cache --- Marco.Pms.CacheHelper/ProjectCache.cs | 46 ++++++--------------------- Marco.Pms.CacheHelper/ReportCache.cs | 5 +-- 2 files changed, 10 insertions(+), 41 deletions(-) diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index df95419..a9ae3af 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -23,6 +23,8 @@ namespace Marco.Pms.CacheHelper _taskCollection = mongoDB.GetCollection("WorkItemDetails"); } + #region=================================================================== Project Cache Helper =================================================================== + public async Task AddProjectDetailsToCache(ProjectMongoDB projectDetails) { await _projectCollection.InsertOneAsync(projectDetails); @@ -36,7 +38,6 @@ namespace Marco.Pms.CacheHelper await _projectCollection.Indexes.CreateOneAsync(indexModel); } - // The method should focus only on inserting data. public async Task AddProjectDetailsListToCache(List projectDetailsList) { // 1. Add a guard clause to avoid an unnecessary database call for an empty list. @@ -49,7 +50,6 @@ namespace Marco.Pms.CacheHelper await _projectCollection.InsertManyAsync(projectDetailsList); await InitializeCollectionAsync(); } - // A private method to handle the one-time setup of the collection's indexes. private async Task InitializeCollectionAsync() { // 1. Define the TTL (Time-To-Live) index on the 'ExpireAt' field. @@ -135,7 +135,9 @@ namespace Marco.Pms.CacheHelper return result.DeletedCount > 0; } - // ------------------------------------------------------- Project InfraStructure ------------------------------------------------------- + #endregion + + #region=================================================================== Project infrastructure Cache Helper =================================================================== public async Task AddBuildngInfraToCache(Guid projectId, Building? building, Floor? floor, WorkArea? workArea, Guid? buildingId) { @@ -161,11 +163,8 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Project not found while adding building. ProjectId: {ProjectId}", projectId); return; } - - //_logger.LogInfo("Building {BuildingId} added to project {ProjectId}", building.Id, projectId); return; } @@ -191,11 +190,8 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Project or building not found while adding floor. ProjectId: {ProjectId}, BuildingId: {BuildingId}", projectId, floor.BuildingId); return; } - - //_logger.LogInfo("Floor {FloorId} added to building {BuildingId} in project {ProjectId}", floor.Id, floor.BuildingId, projectId); return; } @@ -225,16 +221,10 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Project or nested structure not found while adding work area. ProjectId: {ProjectId}, BuildingId: {BuildingId}, FloorId: {FloorId}", projectId, buildingId, workArea.FloorId); return; } - - //_logger.LogInfo("WorkArea {WorkAreaId} added to floor {FloorId} in building {BuildingId}, ProjectId: {ProjectId}", workArea.Id, workArea.FloorId, buildingId, projectId); return; } - - // Fallback case when no valid data was passed - //_logger.LogWarning("No valid infra data provided to add for ProjectId: {ProjectId}", projectId); } public async Task UpdateBuildngInfraToCache(Guid projectId, Building? building, Floor? floor, WorkArea? workArea, Guid? buildingId) { @@ -257,11 +247,9 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Update failed: Project or Building not found. ProjectId: {ProjectId}, BuildingId: {BuildingId}", projectId, building.Id); return false; } - //_logger.LogInfo("Building {BuildingId} updated successfully in project {ProjectId}", building.Id, projectId); return true; } @@ -282,11 +270,8 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Update failed: Project or Floor not found. ProjectId: {ProjectId}, BuildingId: {BuildingId}, FloorId: {FloorId}", projectId, floor.BuildingId, floor.Id); return false; } - - //_logger.LogInfo("Floor {FloorId} updated successfully in Building {BuildingId}, ProjectId: {ProjectId}", floor.Id, floor.BuildingId, projectId); return true; } @@ -308,17 +293,10 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Update failed: Project or WorkArea not found. ProjectId: {ProjectId}, BuildingId: {BuildingId}, FloorId: {FloorId}, WorkAreaId: {WorkAreaId}", - //projectId, buildingId, workArea.FloorId, workArea.Id); return false; } - - //_logger.LogInfo("WorkArea {WorkAreaId} updated successfully in Floor {FloorId}, Building {BuildingId}, ProjectId: {ProjectId}", - //workArea.Id, workArea.FloorId, buildingId, projectId); return true; } - - //_logger.LogWarning("No update performed. Missing or invalid data for ProjectId: {ProjectId}", projectId); return false; } public async Task?> GetBuildingInfraFromCache(Guid projectId) @@ -333,15 +311,6 @@ namespace Marco.Pms.CacheHelper .Project(p => p.Buildings) .FirstOrDefaultAsync(); - //if (buildings == null) - //{ - // _logger.LogWarning("No building infrastructure found for ProjectId: {ProjectId}", projectId); - //} - //else - //{ - // _logger.LogInfo("Fetched {Count} buildings for ProjectId: {ProjectId}", buildings.Count, projectId); - //} - return buildings; } public async Task UpdatePlannedAndCompleteWorksInBuildingFromCache(Guid workAreaId, double plannedWork, double completedWork) @@ -431,8 +400,9 @@ namespace Marco.Pms.CacheHelper return result; } + #endregion - // ------------------------------------------------------- WorkItem ------------------------------------------------------- + #region=================================================================== WorkItem Cache Helper =================================================================== public async Task> GetWorkItemsByWorkAreaIdsFromCache(List workAreaIds) { @@ -517,5 +487,7 @@ namespace Marco.Pms.CacheHelper var result = await _taskCollection.DeleteOneAsync(filter); return result.DeletedCount > 0; } + + #endregion } } diff --git a/Marco.Pms.CacheHelper/ReportCache.cs b/Marco.Pms.CacheHelper/ReportCache.cs index 76009a4..66611a8 100644 --- a/Marco.Pms.CacheHelper/ReportCache.cs +++ b/Marco.Pms.CacheHelper/ReportCache.cs @@ -1,4 +1,3 @@ -using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.MongoDBModels; using Microsoft.Extensions.Configuration; using MongoDB.Driver; @@ -7,12 +6,10 @@ namespace Marco.Pms.CacheHelper { public class ReportCache { - private readonly ApplicationDbContext _context; private readonly IMongoCollection _projectReportCollection; - public ReportCache(ApplicationDbContext context, IConfiguration configuration) + public ReportCache(IConfiguration configuration) { var connectionString = configuration["MongoDB:ConnectionString"]; - _context = context; var mongoUrl = new MongoUrl(connectionString); var client = new MongoClient(mongoUrl); // Your MongoDB connection string var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name -- 2.43.0 From 3bc51f9cd939bfc21aea6de9ed55d1027dc9fef6 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Sat, 12 Jul 2025 13:13:29 +0530 Subject: [PATCH 25/50] Refactor project report APIs to improve performance and readability --- Marco.Pms.CacheHelper/ReportCache.cs | 45 ++ .../ProjectReportEmailMongoDB.cs | 16 + .../Controllers/ReportController.cs | 530 +++++++++++++++--- Marco.Pms.Services/Service/EmailSender.cs | 26 +- 4 files changed, 523 insertions(+), 94 deletions(-) create mode 100644 Marco.Pms.CacheHelper/ReportCache.cs create mode 100644 Marco.Pms.Model/MongoDBModels/ProjectReportEmailMongoDB.cs diff --git a/Marco.Pms.CacheHelper/ReportCache.cs b/Marco.Pms.CacheHelper/ReportCache.cs new file mode 100644 index 0000000..76009a4 --- /dev/null +++ b/Marco.Pms.CacheHelper/ReportCache.cs @@ -0,0 +1,45 @@ +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.MongoDBModels; +using Microsoft.Extensions.Configuration; +using MongoDB.Driver; + +namespace Marco.Pms.CacheHelper +{ + public class ReportCache + { + private readonly ApplicationDbContext _context; + private readonly IMongoCollection _projectReportCollection; + public ReportCache(ApplicationDbContext context, IConfiguration configuration) + { + var connectionString = configuration["MongoDB:ConnectionString"]; + _context = context; + var mongoUrl = new MongoUrl(connectionString); + var client = new MongoClient(mongoUrl); // Your MongoDB connection string + var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name + _projectReportCollection = mongoDB.GetCollection("ProjectReportMail"); + } + + /// + /// Retrieves project report emails from the cache based on their sent status. + /// + /// True to get sent reports, false to get unsent reports. + /// A list of ProjectReportEmailMongoDB objects. + public async Task> GetProjectReportMailFromCache(bool isSent) + { + var filter = Builders.Filter.Eq(p => p.IsSent, isSent); + var reports = await _projectReportCollection.Find(filter).ToListAsync(); + return reports; + } + + /// + /// Adds a project report email to the cache. + /// + /// The ProjectReportEmailMongoDB object to add. + /// A Task representing the asynchronous operation. + public async Task AddProjectReportMailToCache(ProjectReportEmailMongoDB report) + { + // Consider adding validation or logging here. + await _projectReportCollection.InsertOneAsync(report); + } + } +} diff --git a/Marco.Pms.Model/MongoDBModels/ProjectReportEmailMongoDB.cs b/Marco.Pms.Model/MongoDBModels/ProjectReportEmailMongoDB.cs new file mode 100644 index 0000000..519ea4f --- /dev/null +++ b/Marco.Pms.Model/MongoDBModels/ProjectReportEmailMongoDB.cs @@ -0,0 +1,16 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Marco.Pms.Model.MongoDBModels +{ + public class ProjectReportEmailMongoDB + { + [BsonId] // Tells MongoDB this is the primary key (_id) + [BsonRepresentation(BsonType.ObjectId)] // Optional: if your _id is ObjectId + public string Id { get; set; } = string.Empty; + public string? Body { get; set; } + public string? Subject { get; set; } + public List? Receivers { get; set; } + public bool IsSent { get; set; } = false; + } +} diff --git a/Marco.Pms.Services/Controllers/ReportController.cs b/Marco.Pms.Services/Controllers/ReportController.cs index 11dec58..717a273 100644 --- a/Marco.Pms.Services/Controllers/ReportController.cs +++ b/Marco.Pms.Services/Controllers/ReportController.cs @@ -1,16 +1,19 @@ -using System.Data; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Mail; -using Marco.Pms.Model.Employees; using Marco.Pms.Model.Mail; +using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Utilities; using Marco.Pms.Services.Helpers; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; using MongoDB.Driver; +using System.Data; +using System.Globalization; +using System.Net.Mail; namespace Marco.Pms.Services.Controllers { @@ -25,7 +28,11 @@ namespace Marco.Pms.Services.Controllers private readonly UserHelper _userHelper; private readonly IWebHostEnvironment _env; private readonly ReportHelper _reportHelper; - public ReportController(ApplicationDbContext context, IEmailSender emailSender, ILoggingService logger, UserHelper userHelper, IWebHostEnvironment env, ReportHelper reportHelper) + private readonly IConfiguration _configuration; + private readonly CacheUpdateHelper _cache; + private readonly IServiceScopeFactory _serviceScopeFactory; + public ReportController(ApplicationDbContext context, IEmailSender emailSender, ILoggingService logger, UserHelper userHelper, + IWebHostEnvironment env, ReportHelper reportHelper, IConfiguration configuration, CacheUpdateHelper cache, IServiceScopeFactory serviceScopeFactory) { _context = context; _emailSender = emailSender; @@ -33,27 +40,122 @@ namespace Marco.Pms.Services.Controllers _userHelper = userHelper; _env = env; _reportHelper = reportHelper; + _configuration = configuration; + _cache = cache; + _serviceScopeFactory = serviceScopeFactory; } - [HttpPost("set-mail")] + /// + /// Adds new mail details for a project report. + /// + /// The mail details data. + /// An API response indicating success or failure. + [HttpPost("mail-details")] // More specific route for adding mail details public async Task AddMailDetails([FromBody] MailDetailsDto mailDetailsDto) { + // 1. Get Tenant ID and Basic Authorization Check Guid tenantId = _userHelper.GetTenantId(); - MailDetails mailDetails = new MailDetails + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Authorization Error: Attempt to add mail details with an empty or invalid tenant ID."); + return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "Tenant ID not found or invalid.", 401)); + } + + // 2. Input Validation (Leverage Model Validation attributes on DTO) + if (mailDetailsDto == null) + { + _logger.LogWarning("Validation Error: MailDetails DTO is null. TenantId: {TenantId}", tenantId); + return BadRequest(ApiResponse.ErrorResponse("Invalid Data", "Request body is empty.", 400)); + } + + // Ensure ProjectId and Recipient are not empty + if (mailDetailsDto.ProjectId == Guid.Empty) + { + _logger.LogWarning("Validation Error: Project ID is empty. TenantId: {TenantId}", tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Project ID cannot be empty.", 400)); + } + + if (string.IsNullOrWhiteSpace(mailDetailsDto.Recipient)) + { + _logger.LogWarning("Validation Error: Recipient email is empty. ProjectId: {ProjectId}, TenantId: {TenantId}", mailDetailsDto.ProjectId, tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Recipient email cannot be empty.", 400)); + } + + // Optional: Validate email format using regex or System.Net.Mail.MailAddress + try + { + var mailAddress = new MailAddress(mailDetailsDto.Recipient); + _logger.LogInfo("nothing"); + } + catch (FormatException) + { + _logger.LogWarning("Validation Error: Invalid recipient email format '{Recipient}'. ProjectId: {ProjectId}, TenantId: {TenantId}", mailDetailsDto.Recipient, mailDetailsDto.ProjectId, tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Invalid recipient email format.", 400)); + } + + // 3. Validate MailListId (Foreign Key Check) + // Ensure the MailListId refers to an existing MailBody (template) within the same tenant. + if (mailDetailsDto.MailListId != Guid.Empty) // Only validate if a MailListId is provided + { + bool mailTemplateExists; + try + { + mailTemplateExists = await _context.MailingList + .AsNoTracking() + .AnyAsync(m => m.Id == mailDetailsDto.MailListId && m.TenantId == tenantId); + } + catch (Exception ex) + { + _logger.LogError("Database Error: Failed to check existence of MailListId '{MailListId}' for TenantId: {TenantId}. : {Error}", mailDetailsDto.MailListId, tenantId, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while validating mail template.", 500)); + } + + if (!mailTemplateExists) + { + _logger.LogWarning("Validation Error: Provided MailListId '{MailListId}' does not exist or does not belong to TenantId: {TenantId}.", mailDetailsDto.MailListId, tenantId); + return NotFound(ApiResponse.ErrorResponse("Invalid Mail Template", "The specified mail template (MailListId) was not found or accessible.", 404)); + } + } + // If MailListId can be null/empty and implies no specific template, adjust logic accordingly. + // Currently assumes it must exist if provided. + + // 4. Create and Add New Mail Details + var newMailDetails = new MailDetails { ProjectId = mailDetailsDto.ProjectId, Recipient = mailDetailsDto.Recipient, Schedule = mailDetailsDto.Schedule, MailListId = mailDetailsDto.MailListId, - TenantId = tenantId + TenantId = tenantId, }; - _context.MailDetails.Add(mailDetails); - await _context.SaveChangesAsync(); - return Ok("Success"); + + try + { + _context.MailDetails.Add(newMailDetails); + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully added new mail details with ID {MailDetailsId} for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}.", newMailDetails.Id, newMailDetails.ProjectId, newMailDetails.Recipient, tenantId); + + // 5. Return Success Response (201 Created is ideal for resource creation) + return StatusCode(201, ApiResponse.SuccessResponse( + newMailDetails, // Return the newly created object (or a DTO of it) + "Mail details added successfully.", + 201)); + } + catch (DbUpdateException dbEx) + { + _logger.LogError("Database Error: Failed to save new mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}. : {Error}", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId, dbEx.Message); + // Check for specific constraint violations if applicable (e.g., duplicate recipient for a project) + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while saving the mail details.", 500)); + } + catch (Exception ex) + { + _logger.LogError("Unexpected Error: An unhandled exception occurred while adding mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}. : {Error}", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500)); + } } - [HttpPost("mail-template")] - public async Task AddMailTemplate([FromBody] MailTemeplateDto mailTemeplateDto) + [HttpPost("mail-template1")] + public async Task AddMailTemplate1([FromBody] MailTemeplateDto mailTemeplateDto) { Guid tenantId = _userHelper.GetTenantId(); if (string.IsNullOrWhiteSpace(mailTemeplateDto.Body) && string.IsNullOrWhiteSpace(mailTemeplateDto.Title)) @@ -80,116 +182,376 @@ namespace Marco.Pms.Services.Controllers return Ok("Success"); } + /// + /// Adds a new mail template. + /// + /// The mail template data. + /// An API response indicating success or failure. + [HttpPost("mail-template")] // More specific route for adding a template + public async Task AddMailTemplate([FromBody] MailTemeplateDto mailTemplateDto) // Renamed parameter for consistency + { + // 1. Get Tenant ID and Basic Authorization Check + Guid tenantId = _userHelper.GetTenantId(); + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Authorization Error: Attempt to add mail template with an empty or invalid tenant ID."); + return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "Tenant ID not found or invalid.", 401)); + } + + // 2. Input Validation (Moved to model validation if possible, or keep explicit) + // Use proper model validation attributes ([Required], [StringLength]) on MailTemeplateDto + // and rely on ASP.NET Core's automatic model validation if possible. + // If not, these checks are good. + if (mailTemplateDto == null) + { + _logger.LogWarning("Validation Error: Mail template DTO is null."); + return BadRequest(ApiResponse.ErrorResponse("Invalid Data", "Request body is empty.", 400)); + } + + if (string.IsNullOrWhiteSpace(mailTemplateDto.Title)) + { + _logger.LogWarning("Validation Error: Mail template title is empty or whitespace. TenantId: {TenantId}", tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Mail template title cannot be empty.", 400)); + } + + // The original logic checked both body and title, but often a template needs at least a title. + // Re-evalute if body can be empty. If so, remove the body check. Assuming title is always mandatory. + // If both body and title are empty, it's definitely invalid. + if (string.IsNullOrWhiteSpace(mailTemplateDto.Body) && string.IsNullOrWhiteSpace(mailTemplateDto.Subject)) + { + _logger.LogWarning("Validation Error: Mail template body and subject are both empty or whitespace for title '{Title}'. TenantId: {TenantId}", mailTemplateDto.Title, tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Mail template body or subject must be provided.", 400)); + } + + // 3. Check for Existing Template Title (Case-Insensitive) + // Use AsNoTracking() for read-only query + MailingList? existingTemplate; + try + { + existingTemplate = await _context.MailingList + .AsNoTracking() // Important for read-only checks + .FirstOrDefaultAsync(t => t.Title.ToLower() == mailTemplateDto.Title.ToLower() && t.TenantId == tenantId); // IMPORTANT: Filter by TenantId! + } + catch (Exception ex) + { + _logger.LogError("Database Error: Failed to check for existing mail template with title '{Title}' for TenantId: {TenantId}.: {Error}", mailTemplateDto.Title, tenantId, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while checking for existing templates.", 500)); + } + + + if (existingTemplate != null) + { + _logger.LogWarning("Conflict Error: User tries to add email template with title '{Title}' which already exists for TenantId: {TenantId}.", mailTemplateDto.Title, tenantId); + return Conflict(ApiResponse.ErrorResponse("Conflict", $"Email template with title '{mailTemplateDto.Title}' already exists.", 409)); + } + + // 4. Create and Add New Template + var newMailingList = new MailingList + { + Title = mailTemplateDto.Title, + Body = mailTemplateDto.Body, + Subject = mailTemplateDto.Subject, + Keywords = mailTemplateDto.Keywords, + TenantId = tenantId, + }; + + try + { + _context.MailingList.Add(newMailingList); + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully added new mail template with ID {TemplateId} and title '{Title}' for TenantId: {TenantId}.", newMailingList.Id, newMailingList.Title, tenantId); + + // 5. Return Success Response (201 Created is ideal for resource creation) + // It's good practice to return the created resource or its ID. + return StatusCode(201, ApiResponse.SuccessResponse( + newMailingList, // Return the newly created object (or a DTO of it) + "Mail template added successfully.", + 201)); + } + catch (DbUpdateException dbEx) + { + _logger.LogError("Database Error: Failed to save new mail template '{Title}' for TenantId: {TenantId}. : {Error}", mailTemplateDto.Title, tenantId, dbEx.Message); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while saving the mail template.", 500)); + } + catch (Exception ex) + { + _logger.LogError("Unexpected Error: An unhandled exception occurred while adding mail template '{Title}' for TenantId: {TenantId}. : {Error}", mailTemplateDto.Title, tenantId, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500)); + } + } + [HttpGet("project-statistics")] public async Task SendProjectReport() { Guid tenantId = _userHelper.GetTenantId(); - // Use AsNoTracking() for read-only queries to improve performance - List mailDetails = await _context.MailDetails + // 1. OPTIMIZATION: Perform grouping and projection on the database server. + // This is far more efficient than loading all entities into memory. + var projectMailGroups = await _context.MailDetails .AsNoTracking() - .Include(m => m.MailBody) .Where(m => m.TenantId == tenantId) - .ToListAsync(); - - int successCount = 0; - int notFoundCount = 0; - int invalidIdCount = 0; - - var groupedMails = mailDetails .GroupBy(m => new { m.ProjectId, m.MailListId }) .Select(g => new { ProjectId = g.Key.ProjectId, - MailListId = g.Key.MailListId, Recipients = g.Select(m => m.Recipient).Distinct().ToList(), - MailBody = g.FirstOrDefault()?.MailBody?.Body ?? "", - Subject = g.FirstOrDefault()?.MailBody?.Subject ?? string.Empty, + // Project the mail body and subject from the first record in the group + MailInfo = g.Select(m => new { Body = m.MailBody != null ? m.MailBody.Body : "", Subject = m.MailBody != null ? m.MailBody.Subject : "" }).FirstOrDefault() }) - .ToList(); + .ToListAsync(); - var semaphore = new SemaphoreSlim(1); - - // Using Task.WhenAll to send reports concurrently for better performance - var sendTasks = groupedMails.Select(async mailDetail => + if (!projectMailGroups.Any()) { - await semaphore.WaitAsync(); - try + return Ok(ApiResponse.SuccessResponse(new { }, "No projects found to send reports for.", 200)); + } + + int successCount = 0; + int notFoundCount = 0; + int invalidIdCount = 0; + int failureCount = 0; + + // 2. OPTIMIZATION: Use true concurrency by removing SemaphoreSlim(1) + // and giving each task its own isolated set of services (including DbContext). + var sendTasks = projectMailGroups.Select(async mailGroup => + { + // SOLUTION: Create a new Dependency Injection scope for each parallel task. + using (var scope = _serviceScopeFactory.CreateScope()) { - var response = await GetProjectStatistics(mailDetail.ProjectId, mailDetail.Recipients, mailDetail.MailBody, mailDetail.Subject, tenantId); - if (response.StatusCode == 200) - Interlocked.Increment(ref successCount); - else if (response.StatusCode == 404) - Interlocked.Increment(ref notFoundCount); - else if (response.StatusCode == 400) - Interlocked.Increment(ref invalidIdCount); - } - finally - { - semaphore.Release(); + // Resolve a new instance of the helper from this isolated scope. + // This ensures each task gets its own thread-safe DbContext. + var reportHelper = scope.ServiceProvider.GetRequiredService(); + + try + { + // Ensure MailInfo and ProjectId are valid before proceeding + if (mailGroup.MailInfo == null || mailGroup.ProjectId == Guid.Empty) + { + Interlocked.Increment(ref invalidIdCount); + return; + } + + var response = await reportHelper.GetProjectStatistics( + mailGroup.ProjectId, + mailGroup.Recipients, + mailGroup.MailInfo.Body, + mailGroup.MailInfo.Subject, + tenantId); + + // Use a switch expression for cleaner counting + switch (response.StatusCode) + { + case 200: Interlocked.Increment(ref successCount); break; + case 404: Interlocked.Increment(ref notFoundCount); break; + case 400: Interlocked.Increment(ref invalidIdCount); break; + default: Interlocked.Increment(ref failureCount); break; + } + } + catch (Exception ex) + { + // 3. OPTIMIZATION: Make the process resilient. + // If one task fails unexpectedly, log it and continue with others. + _logger.LogError("Failed to send report for project {ProjectId} : {Error}", mailGroup.ProjectId, ex.Message); + Interlocked.Increment(ref failureCount); + } } }).ToList(); await Task.WhenAll(sendTasks); - //var response = await GetProjectStatistics(Guid.Parse("2618eb89-2823-11f0-9d9e-bc241163f504"), "ashutosh.nehete@marcoaiot.com", tenantId); + var summaryMessage = $"Processing complete. Success: {successCount}, Not Found: {notFoundCount}, Invalid ID: {invalidIdCount}, Failures: {failureCount}."; _logger.LogInfo( - "Emails of project reports sent for tenant {TenantId}. Successfully sent: {SuccessCount}, Projects not found: {NotFoundCount}, Invalid IDs: {InvalidIdsCount}", - tenantId, successCount, notFoundCount, invalidIdCount); + "Project report sending complete for tenant {TenantId}. Success: {SuccessCount}, Not Found: {NotFoundCount}, Invalid ID: {InvalidIdCount}, Failures: {FailureCount}", + tenantId, successCount, notFoundCount, invalidIdCount, failureCount); return Ok(ApiResponse.SuccessResponse( - new { }, - $"Reports sent successfully: {successCount}. Projects not found: {notFoundCount}. Invalid IDs: {invalidIdCount}.", + new { successCount, notFoundCount, invalidIdCount, failureCount }, + summaryMessage, 200)); } - /// - /// Retrieves project statistics for a given project ID and sends an email report. - /// - /// The ID of the project. - /// The email address of the recipient. - /// An ApiResponse indicating the success or failure of retrieving statistics and sending the email. - private async Task> GetProjectStatistics(Guid projectId, List recipientEmails, string body, string subject, Guid tenantId) + + //[HttpPost("add-report-mail1")] + //public async Task StoreProjectStatistics1() + //{ + + // Guid tenantId = _userHelper.GetTenantId(); + + // // Use AsNoTracking() for read-only queries to improve performance + // List mailDetails = await _context.MailDetails + // .AsNoTracking() + // .Include(m => m.MailBody) + // .Where(m => m.TenantId == tenantId) + // .ToListAsync(); + + // var groupedMails = mailDetails + // .GroupBy(m => new { m.ProjectId, m.MailListId }) + // .Select(g => new + // { + // ProjectId = g.Key.ProjectId, + // MailListId = g.Key.MailListId, + // Recipients = g.Select(m => m.Recipient).Distinct().ToList(), + // MailBody = g.FirstOrDefault()?.MailBody?.Body ?? "", + // Subject = g.FirstOrDefault()?.MailBody?.Subject ?? string.Empty, + // }) + // .ToList(); + // foreach (var groupMail in groupedMails) + // { + // var projectId = groupMail.ProjectId; + // var body = groupMail.MailBody; + // var subject = groupMail.Subject; + // var receivers = groupMail.Recipients; + // if (projectId == Guid.Empty) + // { + // _logger.LogError("Provided empty project ID while fetching project report."); + // return NotFound(ApiResponse.ErrorResponse("Provided empty Project ID.", "Provided empty Project ID.", 400)); + // } + + + // var statisticReport = await _reportHelper.GetDailyProjectReport(projectId, tenantId); + + // if (statisticReport == null) + // { + // _logger.LogWarning("User attempted to fetch project progress for project ID {ProjectId} but not found.", projectId); + // return NotFound(ApiResponse.ErrorResponse("Project not found.", "Project not found.", 404)); + // } + // var date = statisticReport.Date.ToString("dd-MMM-yyyy", CultureInfo.InvariantCulture); + + // // Send Email + // var emailBody = await _emailSender.SendProjectStatisticsEmail(new List(), body, subject, statisticReport); + // var subjectReplacements = new Dictionary + // { + // {"DATE", date }, + // {"PROJECT_NAME", statisticReport.ProjectName} + // }; + // foreach (var item in subjectReplacements) + // { + // subject = subject.Replace($"{{{{{item.Key}}}}}", item.Value); + // } + // string env = _configuration["environment:Title"] ?? string.Empty; + // if (string.IsNullOrWhiteSpace(env)) + // { + // subject = $"{subject}"; + // } + // else + // { + // subject = $"({env}) {subject}"; + // } + // var mail = new ProjectReportEmailMongoDB + // { + // IsSent = false, + // Body = emailBody, + // Receivers = receivers, + // Subject = subject, + // }; + // await _cache.AddProjectReportMail(mail); + // } + // return Ok(ApiResponse.SuccessResponse("Project Report Mail is stored in MongoDB", "Project Report Mail is stored in MongoDB", 200)); + //} + + [HttpPost("add-report-mail")] + public async Task StoreProjectStatistics() { + Guid tenantId = _userHelper.GetTenantId(); - if (projectId == Guid.Empty) + // 1. Database-Side Grouping (Still the most efficient way to get initial data) + var projectMailGroups = await _context.MailDetails + .AsNoTracking() + .Where(m => m.TenantId == tenantId && m.ProjectId != Guid.Empty) + .GroupBy(m => new { m.ProjectId, m.MailListId }) + .Select(g => new + { + g.Key.ProjectId, + Recipients = g.Select(m => m.Recipient).Distinct().ToList(), + MailInfo = g.Select(m => new { Body = m.MailBody != null ? m.MailBody.Body : "", Subject = m.MailBody != null ? m.MailBody.Subject : "" }).FirstOrDefault() + }) + .ToListAsync(); + + if (!projectMailGroups.Any()) { - _logger.LogError("Provided empty project ID while fetching project report."); - return ApiResponse.ErrorResponse("Provided empty Project ID.", "Provided empty Project ID.", 400); + _logger.LogInfo("No project mail details found for tenant {TenantId} to process.", tenantId); + return Ok(ApiResponse.SuccessResponse("No project reports to generate.", "No project reports to generate.", 200)); } + string env = _configuration["environment:Title"] ?? string.Empty; - var statisticReport = await _reportHelper.GetDailyProjectReport(projectId, tenantId); - - if (statisticReport == null) + // 2. Process each group concurrently, but with isolated DBContexts. + var processingTasks = projectMailGroups.Select(async group => { - _logger.LogWarning("User attempted to fetch project progress for project ID {ProjectId} but not found.", projectId); - return ApiResponse.ErrorResponse("Project not found.", "Project not found.", 404); - } + // SOLUTION: Create a new DI scope for each parallel task. + using (var scope = _serviceScopeFactory.CreateScope()) + { + // Resolve services from this new, isolated scope. + // These helpers will get their own fresh DbContext instance. + var reportHelper = scope.ServiceProvider.GetRequiredService(); + var emailSender = scope.ServiceProvider.GetRequiredService(); + var cache = scope.ServiceProvider.GetRequiredService(); // e.g., IProjectReportCache - // Send Email - var emailBody = await _emailSender.SendProjectStatisticsEmail(recipientEmails, body, subject, statisticReport); - var employee = await _context.Employees.FirstOrDefaultAsync(e => e.Email != null && recipientEmails.Contains(e.Email)) ?? new Employee(); - - List mailLogs = new List(); - foreach (var recipientEmail in recipientEmails) - { - mailLogs.Add( - new MailLog + // The rest of the logic is the same, but now it's thread-safe. + try { - ProjectId = projectId, - EmailId = recipientEmail, - Body = emailBody, - EmployeeId = employee.Id, - TimeStamp = DateTime.UtcNow, - TenantId = tenantId - }); + var projectId = group.ProjectId; + var statisticReport = await reportHelper.GetDailyProjectReport(projectId, tenantId); + + if (statisticReport == null) + { + _logger.LogWarning("Statistic report for project ID {ProjectId} not found. Skipping.", projectId); + return; + } + + if (group.MailInfo == null) + { + _logger.LogWarning("MailBody info for project ID {ProjectId} not found. Skipping.", projectId); + return; + } + + var date = statisticReport.Date.ToString("dd-MMM-yyyy", CultureInfo.InvariantCulture); + // Assuming the first param to SendProjectStatisticsEmail was just a placeholder + var emailBody = await emailSender.SendProjectStatisticsEmail(new List(), group.MailInfo.Body, string.Empty, statisticReport); + + string subject = group.MailInfo.Subject + .Replace("{{DATE}}", date) + .Replace("{{PROJECT_NAME}}", statisticReport.ProjectName); + + subject = string.IsNullOrWhiteSpace(env) ? subject : $"({env}) {subject}"; + + var mail = new ProjectReportEmailMongoDB + { + IsSent = false, + Body = emailBody, + Receivers = group.Recipients, + Subject = subject, + }; + + await cache.AddProjectReportMail(mail); + } + catch (Exception ex) + { + // It's good practice to log any unexpected errors within a concurrent task. + _logger.LogError("Failed to process project report for ProjectId {ProjectId} : {Error}", group.ProjectId, ex.Message); + } + } + }); + + // Await all the concurrent, now thread-safe, tasks. + await Task.WhenAll(processingTasks); + + return Ok(ApiResponse.SuccessResponse( + $"{projectMailGroups.Count} Project Report Mail(s) are queued for storage.", + "Project Report Mail processing initiated.", + 200)); + } + + + [HttpGet("report-mail")] + public async Task GetProjectStatisticsFromCache() + { + var mailList = await _cache.GetProjectReportMail(false); + if (mailList == null) + { + return NotFound(ApiResponse.ErrorResponse("Not mail found", "Not mail found", 404)); } - _context.MailLogs.AddRange(mailLogs); - - await _context.SaveChangesAsync(); - return ApiResponse.SuccessResponse(statisticReport, "Email sent successfully", 200); + return Ok(ApiResponse.SuccessResponse(mailList, "Fetched list of mail body successfully", 200)); } } } diff --git a/Marco.Pms.Services/Service/EmailSender.cs b/Marco.Pms.Services/Service/EmailSender.cs index 568510a..4d66a4f 100644 --- a/Marco.Pms.Services/Service/EmailSender.cs +++ b/Marco.Pms.Services/Service/EmailSender.cs @@ -150,18 +150,24 @@ namespace MarcoBMS.Services.Service emailBody = emailBody.Replace("{{TEAM_ON_SITE}}", BuildTeamOnSiteHtml(report.TeamOnSite)); emailBody = emailBody.Replace("{{PERFORMED_TASK}}", BuildPerformedTaskHtml(report.PerformedTasks, report.Date)); emailBody = emailBody.Replace("{{PERFORMED_ATTENDANCE}}", BuildPerformedAttendanceHtml(report.PerformedAttendance)); - var subjectReplacements = new Dictionary + if (!string.IsNullOrWhiteSpace(subject)) { - {"DATE", date }, - {"PROJECT_NAME", report.ProjectName} - }; - foreach (var item in subjectReplacements) - { - subject = subject.Replace($"{{{{{item.Key}}}}}", item.Value); + var subjectReplacements = new Dictionary + { + {"DATE", date }, + {"PROJECT_NAME", report.ProjectName} + }; + foreach (var item in subjectReplacements) + { + subject = subject.Replace($"{{{{{item.Key}}}}}", item.Value); + } + string env = _configuration["environment:Title"] ?? string.Empty; + subject = CheckSubject(subject); + } + if (toEmails.Count > 0) + { + await SendEmailAsync(toEmails, subject, emailBody); } - string env = _configuration["environment:Title"] ?? string.Empty; - subject = CheckSubject(subject); - await SendEmailAsync(toEmails, subject, emailBody); return emailBody; } public async Task SendOTP(List toEmails, string emailBody, string name, string otp, string subject) -- 2.43.0 From 7d160a9a52f47d0b26c4c88dbcea60ccc18c9364 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Sat, 12 Jul 2025 13:14:15 +0530 Subject: [PATCH 26/50] Refactored the function to add project in cache and added auto Mapper --- Marco.Pms.CacheHelper/ProjectCache.cs | 135 +----- .../Controllers/AttendanceController.cs | 28 +- .../Controllers/DashboardController.cs | 2 +- .../Controllers/EmployeeController.cs | 9 +- .../Controllers/ImageController.cs | 6 +- .../Controllers/ProjectController.cs | 286 ++++++++---- .../Helpers/CacheUpdateHelper.cs | 432 +++++++++++++++++- Marco.Pms.Services/Helpers/ProjectsHelper.cs | 77 +--- Marco.Pms.Services/Helpers/ReportHelper.cs | 99 +++- .../MappingProfiles/ProjectMappingProfile.cs | 30 ++ Marco.Pms.Services/Marco.Pms.Services.csproj | 1 + Marco.Pms.Services/Program.cs | 269 ++++++----- Marco.Pms.Services/Service/ILoggingService.cs | 5 +- Marco.Pms.Services/Service/LoggingServices.cs | 18 +- .../Service/PermissionServices.cs | 40 +- 15 files changed, 958 insertions(+), 479 deletions(-) create mode 100644 Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index 9b2036d..1fd36f4 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -24,132 +24,14 @@ namespace Marco.Pms.CacheHelper _projetCollection = mongoDB.GetCollection("ProjectDetails"); _taskCollection = mongoDB.GetCollection("WorkItemDetails"); } - public async Task AddProjectDetailsToCache(Project project) + + public async Task AddProjectDetailsToCache(ProjectMongoDB projectDetails) { - //_logger.LogInfo("[AddProjectDetails] Initiated for ProjectId: {ProjectId}", project.Id); - - var projectDetails = new ProjectMongoDB - { - Id = project.Id.ToString(), - Name = project.Name, - ShortName = project.ShortName, - ProjectAddress = project.ProjectAddress, - StartDate = project.StartDate, - EndDate = project.EndDate, - ContactPerson = project.ContactPerson - }; - - // Get project status - var status = await _context.StatusMasters - .AsNoTracking() - .FirstOrDefaultAsync(s => s.Id == project.ProjectStatusId); - - projectDetails.ProjectStatus = new StatusMasterMongoDB - { - Id = status?.Id.ToString(), - Status = status?.Status - }; - - // Get project team size - var teamSize = await _context.ProjectAllocations - .AsNoTracking() - .CountAsync(pa => pa.ProjectId == project.Id && pa.IsActive); - - projectDetails.TeamSize = teamSize; - - // Fetch related infrastructure in parallel - var buildings = await _context.Buildings - .AsNoTracking() - .Where(b => b.ProjectId == project.Id) - .ToListAsync(); - var buildingIds = buildings.Select(b => b.Id).ToList(); - - var floors = await _context.Floor - .AsNoTracking() - .Where(f => buildingIds.Contains(f.BuildingId)) - .ToListAsync(); - - var floorIds = floors.Select(f => f.Id).ToList(); - - var workAreas = await _context.WorkAreas - .AsNoTracking() - .Where(wa => floorIds.Contains(wa.FloorId)) - .ToListAsync(); - var workAreaIds = workAreas.Select(wa => wa.Id).ToList(); - - var workItems = await _context.WorkItems - .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) - .ToListAsync(); - - double totalPlannedWork = 0, totalCompletedWork = 0; - - var buildingMongoList = new List(); - - foreach (var building in buildings) - { - double buildingPlanned = 0, buildingCompleted = 0; - var buildingFloors = floors.Where(f => f.BuildingId == building.Id).ToList(); - - var floorMongoList = new List(); - foreach (var floor in buildingFloors) - { - double floorPlanned = 0, floorCompleted = 0; - var floorWorkAreas = workAreas.Where(wa => wa.FloorId == floor.Id).ToList(); - - var workAreaMongoList = new List(); - foreach (var wa in floorWorkAreas) - { - var items = workItems.Where(wi => wi.WorkAreaId == wa.Id).ToList(); - double waPlanned = items.Sum(wi => wi.PlannedWork); - double waCompleted = items.Sum(wi => wi.CompletedWork); - - workAreaMongoList.Add(new WorkAreaMongoDB - { - Id = wa.Id.ToString(), - FloorId = wa.FloorId.ToString(), - AreaName = wa.AreaName, - PlannedWork = waPlanned, - CompletedWork = waCompleted - }); - - floorPlanned += waPlanned; - floorCompleted += waCompleted; - } - - floorMongoList.Add(new FloorMongoDB - { - Id = floor.Id.ToString(), - BuildingId = floor.BuildingId.ToString(), - FloorName = floor.FloorName, - PlannedWork = floorPlanned, - CompletedWork = floorCompleted, - WorkAreas = workAreaMongoList - }); - - buildingPlanned += floorPlanned; - buildingCompleted += floorCompleted; - } - - buildingMongoList.Add(new BuildingMongoDB - { - Id = building.Id.ToString(), - ProjectId = building.ProjectId.ToString(), - BuildingName = building.Name, - Description = building.Description, - PlannedWork = buildingPlanned, - CompletedWork = buildingCompleted, - Floors = floorMongoList - }); - - totalPlannedWork += buildingPlanned; - totalCompletedWork += buildingCompleted; - } - - projectDetails.Buildings = buildingMongoList; - projectDetails.PlannedWork = totalPlannedWork; - projectDetails.CompletedWork = totalCompletedWork; - await _projetCollection.InsertOneAsync(projectDetails); + } + public async Task AddProjectDetailsListToCache(List projectDetailsList) + { + await _projetCollection.InsertManyAsync(projectDetailsList); //_logger.LogInfo("[AddProjectDetails] Project details inserted in MongoDB for ProjectId: {ProjectId}", project.Id); } public async Task UpdateProjectDetailsOnlyToCache(Project project) @@ -218,7 +100,7 @@ namespace Marco.Pms.CacheHelper //_logger.LogInfo("Successfully fetched project details (excluding Buildings) for ProjectId: {ProjectId}", projectId); return project; } - public async Task?> GetProjectDetailsListFromCache(List projectIds) + public async Task> GetProjectDetailsListFromCache(List projectIds) { List stringProjectIds = projectIds.Select(p => p.ToString()).ToList(); var filter = Builders.Filter.In(p => p.Id, stringProjectIds); @@ -229,6 +111,9 @@ namespace Marco.Pms.CacheHelper .ToListAsync(); return projects; } + + // ------------------------------------------------------- Project InfraStructure ------------------------------------------------------- + public async Task AddBuildngInfraToCache(Guid projectId, Building? building, Floor? floor, WorkArea? workArea, Guid? buildingId) { var stringProjectId = projectId.ToString(); diff --git a/Marco.Pms.Services/Controllers/AttendanceController.cs b/Marco.Pms.Services/Controllers/AttendanceController.cs index 2622323..4c2f2c1 100644 --- a/Marco.Pms.Services/Controllers/AttendanceController.cs +++ b/Marco.Pms.Services/Controllers/AttendanceController.cs @@ -1,8 +1,8 @@ -using System.Globalization; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.AttendanceModule; using Marco.Pms.Model.Dtos.Attendance; using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; +using System.Globalization; using Document = Marco.Pms.Model.DocumentManager.Document; namespace MarcoBMS.Services.Controllers @@ -61,7 +62,13 @@ namespace MarcoBMS.Services.Controllers { Guid TenantId = GetTenantId(); - List lstAttendance = await _context.AttendanceLogs.Include(a => a.Document).Include(a => a.Employee).Include(a => a.UpdatedByEmployee).Where(c => c.AttendanceId == attendanceid && c.TenantId == TenantId).ToListAsync(); + List lstAttendance = await _context.AttendanceLogs + .Include(a => a.Document) + .Include(a => a.Employee) + .Include(a => a.UpdatedByEmployee) + .Where(c => c.AttendanceId == attendanceid && c.TenantId == TenantId) + .ToListAsync(); + List attendanceLogVMs = new List(); foreach (var attendanceLog in lstAttendance) { @@ -139,9 +146,9 @@ namespace MarcoBMS.Services.Controllers { Guid TenantId = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var hasTeamAttendancePermission = await _permission.HasPermission(new Guid("915e6bff-65f6-4e3f-aea8-3fd217d3ea9e"), LoggedInEmployee.Id); - var hasSelfAttendancePermission = await _permission.HasPermission(new Guid("ccb0589f-712b-43de-92ed-5b6088e7dc4e"), LoggedInEmployee.Id); - var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId.ToString()); + var hasTeamAttendancePermission = await _permission.HasPermission(PermissionsMaster.TeamAttendance, LoggedInEmployee.Id); + var hasSelfAttendancePermission = await _permission.HasPermission(PermissionsMaster.SelfAttendance, LoggedInEmployee.Id); + var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId); if (!hasProjectPermission) { @@ -255,9 +262,9 @@ namespace MarcoBMS.Services.Controllers { Guid TenantId = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var hasTeamAttendancePermission = await _permission.HasPermission(new Guid("915e6bff-65f6-4e3f-aea8-3fd217d3ea9e"), LoggedInEmployee.Id); - var hasSelfAttendancePermission = await _permission.HasPermission(new Guid("ccb0589f-712b-43de-92ed-5b6088e7dc4e"), LoggedInEmployee.Id); - var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId.ToString()); + var hasTeamAttendancePermission = await _permission.HasPermission(PermissionsMaster.TeamAttendance, LoggedInEmployee.Id); + var hasSelfAttendancePermission = await _permission.HasPermission(PermissionsMaster.SelfAttendance, LoggedInEmployee.Id); + var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId); if (!hasProjectPermission) { @@ -361,7 +368,7 @@ namespace MarcoBMS.Services.Controllers Guid TenantId = GetTenantId(); Employee LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var result = new List(); - var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId.ToString()); + var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId); if (!hasProjectPermission) { @@ -371,7 +378,6 @@ namespace MarcoBMS.Services.Controllers List lstAttendance = await _context.Attendes.Where(c => c.ProjectID == projectId && c.Activity == ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE && c.TenantId == TenantId).ToListAsync(); - List projectteam = await _projectsHelper.GetTeamByProject(TenantId, projectId, true); var idList = projectteam.Select(p => p.EmployeeId).ToList(); var jobRole = await _context.JobRoles.ToListAsync(); diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index 3829cdc..f2332df 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -516,7 +516,7 @@ namespace Marco.Pms.Services.Controllers // Step 2: Permission check var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - bool hasAssigned = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.ToString()); + bool hasAssigned = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId); if (!hasAssigned) { diff --git a/Marco.Pms.Services/Controllers/EmployeeController.cs b/Marco.Pms.Services/Controllers/EmployeeController.cs index 9884e53..2f0ca5e 100644 --- a/Marco.Pms.Services/Controllers/EmployeeController.cs +++ b/Marco.Pms.Services/Controllers/EmployeeController.cs @@ -1,6 +1,4 @@ -using System.Data; -using System.Net; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Attendance; using Marco.Pms.Model.Dtos.Employees; using Marco.Pms.Model.Employees; @@ -18,6 +16,8 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; +using System.Data; +using System.Net; namespace MarcoBMS.Services.Controllers { @@ -119,8 +119,7 @@ namespace MarcoBMS.Services.Controllers loggedInEmployee.Id, projectid ?? Guid.Empty, ShowInactive); // Step 3: Fetch project access and permissions - List projects = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - var projectIds = projects.Select(p => p.Id).ToList(); + var projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); var hasViewAllEmployeesPermission = await _permission.HasPermission(PermissionsMaster.ViewAllEmployees, loggedInEmployee.Id); var hasViewTeamMembersPermission = await _permission.HasPermission(PermissionsMaster.ViewTeamMembers, loggedInEmployee.Id); diff --git a/Marco.Pms.Services/Controllers/ImageController.cs b/Marco.Pms.Services/Controllers/ImageController.cs index 48fbc3b..9014171 100644 --- a/Marco.Pms.Services/Controllers/ImageController.cs +++ b/Marco.Pms.Services/Controllers/ImageController.cs @@ -1,5 +1,4 @@ -using System.Text.Json; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Activities; using Marco.Pms.Model.Dtos.DocumentManager; using Marco.Pms.Model.Employees; @@ -13,6 +12,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; +using System.Text.Json; namespace Marco.Pms.Services.Controllers { @@ -54,7 +54,7 @@ namespace Marco.Pms.Services.Controllers } // Step 2: Check project access permission - var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.ToString()); + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); if (!hasPermission) { _logger.LogWarning("[GetImageList] Access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 07ddbfd..29f9d04 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,10 +1,10 @@ -using Marco.Pms.DataAccess.Data; +using AutoMapper; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Activities; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Mapper; -using Marco.Pms.Model.Master; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; @@ -36,16 +36,12 @@ namespace MarcoBMS.Services.Controllers private readonly IHubContext _signalR; private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; - private readonly IServiceScopeFactory _serviceScopeFactory; - private readonly Guid ViewProjects; - private readonly Guid ManageProject; - private readonly Guid ViewInfra; - private readonly Guid ManageInfra; + private readonly IMapper _mapper; private readonly Guid tenantId; public ProjectController(ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, RolesHelper rolesHelper, ProjectsHelper projectHelper, - IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IServiceScopeFactory serviceScopeFactory) + IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IMapper mapper) { _context = context; _userHelper = userHelper; @@ -55,16 +51,12 @@ namespace MarcoBMS.Services.Controllers _signalR = signalR; _cache = cache; _permission = permission; - ViewProjects = Guid.Parse("6ea44136-987e-44ba-9e5d-1cf8f5837ebc"); - ManageProject = Guid.Parse("172fc9b6-755b-4f62-ab26-55c34a330614"); - ViewInfra = Guid.Parse("8d7cc6e3-9147-41f7-aaa7-fa507e450bd4"); - ManageInfra = Guid.Parse("f2aee20a-b754-4537-8166-f9507b44585b"); + _mapper = mapper; tenantId = _userHelper.GetTenantId(); - _serviceScopeFactory = serviceScopeFactory; } - [HttpGet("list/basic")] - public async Task GetAllProjects() + [HttpGet("list/basic1")] + public async Task GetAllProjects1() { if (!ModelState.IsValid) { @@ -84,31 +76,113 @@ namespace MarcoBMS.Services.Controllers return Unauthorized(ApiResponse.ErrorResponse("Employee not found.", null, 401)); } + List response = new List(); + List projectIds = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - List projects = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); + List? projectsDetails = await _cache.GetProjectDetailsList(projectIds); + if (projectsDetails == null) + { + List projects = await _context.Projects.Where(p => projectIds.Contains(p.Id)).ToListAsync(); + //using (var scope = _serviceScopeFactory.CreateScope()) + //{ + // var cacheHelper = scope.ServiceProvider.GetRequiredService(); - - // 4. Project projection to ProjectInfoVM - // This part is already quite efficient. - // Ensure ToProjectInfoVMFromProject() is also optimized and doesn't perform N+1 queries. - // If ProjectInfoVM only needs a subset of Project properties, - // you can use a LINQ Select directly on the IQueryable before ToListAsync() - // to fetch only the required columns from the database. - List response = projects - .Select(project => project.ToProjectInfoVMFromProject()) - .ToList(); - - - //List response = new List(); - - //foreach (var project in projects) - //{ - // response.Add(project.ToProjectInfoVMFromProject()); - //} + //} + foreach (var project in projects) + { + await _cache.AddProjectDetails(project); + } + response = projects.Select(p => _mapper.Map(p)).ToList(); + } + else + { + response = projectsDetails.Select(p => _mapper.Map(p)).ToList(); + } return Ok(ApiResponse.SuccessResponse(response, "Success.", 200)); } + [HttpGet("list/basic")] + public async Task GetAllProjects() // Renamed for clarity + { + // Step 1: Get the current user + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + if (loggedInEmployee == null) + { + return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "User could not be identified.", 401)); + } + + _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); + + // Step 2: Get the list of project IDs the user has access to + Guid tenantId = _userHelper.GetTenantId(); // Assuming this is still needed by the helper + List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + + if (accessibleProjectIds == null || !accessibleProjectIds.Any()) + { + _logger.LogInfo("No accessible projects found for EmployeeId {EmployeeId}", loggedInEmployee.Id); + return Ok(ApiResponse>.SuccessResponse(new List(), "Success.", 200)); + } + + // Step 3: Fetch project ViewModels using the optimized, cache-aware helper + var projectVMs = await GetProjectInfosByIdsAsync(accessibleProjectIds); + + // Step 4: Return the final list + _logger.LogInfo("Successfully returned {ProjectCount} projects for EmployeeId {EmployeeId}", projectVMs.Count, loggedInEmployee.Id); + return Ok(ApiResponse>.SuccessResponse(projectVMs, $"{projectVMs.Count} records of project fetchd successfully", 200)); + } + + /// + /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. + /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the + /// database (as Project), updates the cache, and returns a unified list of ViewModels. + /// + /// The list of project IDs to retrieve. + /// A list of ProjectInfoVMs. + private async Task> GetProjectInfosByIdsAsync(List projectIds) + { + // --- Step 1: Fetch from Cache --- + // The cache returns a list of MongoDB documents for the projects it found. + var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var finalViewModels = _mapper.Map>(cachedMongoDocs); + + _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); + + // --- Step 2: Identify Missing Projects --- + // If we found everything in the cache, we can return early. + if (finalViewModels.Count == projectIds.Count) + { + return finalViewModels; + } + + var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id + var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); + + // --- Step 3: Fetch Missing from Database --- + if (missingIds.Any()) + { + _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); + + var projectsFromDb = await _context.Projects + .Where(p => missingIds.Contains(p.Id)) + .AsNoTracking() // Use AsNoTracking for read-only query performance + .ToListAsync(); + + if (projectsFromDb.Any()) + { + // Map the newly fetched projects (from SQL) to their ViewModel + var vmsFromDb = _mapper.Map>(projectsFromDb); + finalViewModels.AddRange(vmsFromDb); + + // --- Step 4: Update Cache with Missing Items in a new scope --- + _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); + await _cache.AddProjectDetailsList(projectsFromDb); + } + } + + return finalViewModels; + } + [HttpGet("list")] public async Task GetAll() { @@ -139,39 +213,63 @@ namespace MarcoBMS.Services.Controllers // projects = await _context.Projects.Where(c => projectsId.Contains(c.Id.ToString()) && c.TenantId == tenantId).ToListAsync(); //} - List projects = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - - - - + //List projects = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); + ////List projects = new List(); + /// List response = new List(); - foreach (var project in projects) + List projectIds = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); + + var projectsDetails = await _cache.GetProjectDetailsList(projectIds); + if (projectsDetails == null) { - var result = project.ToProjectListVMFromProject(); - var team = await _context.ProjectAllocations.Where(p => p.TenantId == tenantId && p.ProjectId == project.Id && p.IsActive == true).ToListAsync(); + List projects = await _context.Projects.Where(p => projectIds.Contains(p.Id)).ToListAsync(); - result.TeamSize = team.Count(); + var teams = await _context.ProjectAllocations.Where(p => p.TenantId == tenantId && projectIds.Contains(p.ProjectId) && p.IsActive == true).ToListAsync(); - List buildings = await _context.Buildings.Where(b => b.ProjectId == project.Id && b.TenantId == tenantId).ToListAsync(); - List idList = buildings.Select(b => b.Id).ToList(); - List floors = await _context.Floor.Where(f => idList.Contains(f.BuildingId) && f.TenantId == tenantId).ToListAsync(); - idList = floors.Select(f => f.Id).ToList(); + List allBuildings = await _context.Buildings.Where(b => projectIds.Contains(b.ProjectId) && b.TenantId == tenantId).ToListAsync(); + List idList = allBuildings.Select(b => b.Id).ToList(); - List workAreas = await _context.WorkAreas.Where(a => idList.Contains(a.FloorId) && a.TenantId == tenantId).ToListAsync(); - idList = workAreas.Select(a => a.Id).ToList(); + List allFloors = await _context.Floor.Where(f => idList.Contains(f.BuildingId) && f.TenantId == tenantId).ToListAsync(); + idList = allFloors.Select(f => f.Id).ToList(); - List workItems = await _context.WorkItems.Where(i => idList.Contains(i.WorkAreaId) && i.TenantId == tenantId).Include(i => i.ActivityMaster).ToListAsync(); - double completedTask = 0; - double plannedTask = 0; - foreach (var workItem in workItems) + List allWorkAreas = await _context.WorkAreas.Where(a => idList.Contains(a.FloorId) && a.TenantId == tenantId).ToListAsync(); + idList = allWorkAreas.Select(a => a.Id).ToList(); + + List allWorkItems = await _context.WorkItems.Where(i => idList.Contains(i.WorkAreaId) && i.TenantId == tenantId).Include(i => i.ActivityMaster).ToListAsync(); + + foreach (var project in projects) { - completedTask += workItem.CompletedWork; - plannedTask += workItem.PlannedWork; + var result = _mapper.Map(project); + var team = teams.Where(p => p.TenantId == tenantId && p.ProjectId == project.Id && p.IsActive == true).ToList(); + + result.TeamSize = team.Count(); + + List buildings = allBuildings.Where(b => b.ProjectId == project.Id && b.TenantId == tenantId).ToList(); + idList = buildings.Select(b => b.Id).ToList(); + + List floors = allFloors.Where(f => idList.Contains(f.BuildingId) && f.TenantId == tenantId).ToList(); + idList = floors.Select(f => f.Id).ToList(); + + List workAreas = allWorkAreas.Where(a => idList.Contains(a.FloorId) && a.TenantId == tenantId).ToList(); + idList = workAreas.Select(a => a.Id).ToList(); + + List workItems = allWorkItems.Where(i => idList.Contains(i.WorkAreaId) && i.TenantId == tenantId).ToList(); + double completedTask = 0; + double plannedTask = 0; + foreach (var workItem in workItems) + { + completedTask += workItem.CompletedWork; + plannedTask += workItem.PlannedWork; + } + result.PlannedWork = plannedTask; + result.CompletedWork = completedTask; + response.Add(result); } - result.PlannedWork = plannedTask; - result.CompletedWork = completedTask; - response.Add(result); + } + else + { + response = projectsDetails.Select(p => _mapper.Map(p)).ToList(); } return Ok(ApiResponse.SuccessResponse(response, "Success.", 200)); @@ -215,7 +313,7 @@ namespace MarcoBMS.Services.Controllers _logger.LogInfo("Details requested by EmployeeId: {EmployeeId} for ProjectId: {ProjectId}", loggedInEmployee.Id, id); // Step 3: Check global view project permission - var hasViewProjectPermission = await _permission.HasPermission(ViewProjects, loggedInEmployee.Id); + var hasViewProjectPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); if (!hasViewProjectPermission) { _logger.LogWarning("ViewProjects permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); @@ -223,7 +321,7 @@ namespace MarcoBMS.Services.Controllers } // Step 4: Check permission for this specific project - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, id.ToString()); + var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, id); if (!hasProjectPermission) { _logger.LogWarning("Project-specific access denied. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployee.Id, id); @@ -238,7 +336,9 @@ namespace MarcoBMS.Services.Controllers var project = await _context.Projects .Include(c => c.ProjectStatus) .FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id); - projectVM = GetProjectViewModel(project); + + projectVM = _mapper.Map(project); + if (project != null) { await _cache.AddProjectDetails(project); @@ -246,23 +346,28 @@ namespace MarcoBMS.Services.Controllers } else { - projectVM = new ProjectVM + projectVM = _mapper.Map(projectDetails); + if (projectVM.ProjectStatus != null) { - Id = projectDetails.Id != null ? Guid.Parse(projectDetails.Id) : Guid.Empty, - Name = projectDetails.Name, - ShortName = projectDetails.ShortName, - ProjectAddress = projectDetails.ProjectAddress, - StartDate = projectDetails.StartDate, - EndDate = projectDetails.EndDate, - ContactPerson = projectDetails.ContactPerson, - ProjectStatus = new StatusMaster - { - Id = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, - Status = projectDetails.ProjectStatus?.Status, - TenantId = tenantId - } - //ProjectStatusId = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, - }; + projectVM.ProjectStatus.TenantId = tenantId; + } + //projectVM = new ProjectVM + //{ + // Id = projectDetails.Id != null ? Guid.Parse(projectDetails.Id) : Guid.Empty, + // Name = projectDetails.Name, + // ShortName = projectDetails.ShortName, + // ProjectAddress = projectDetails.ProjectAddress, + // StartDate = projectDetails.StartDate, + // EndDate = projectDetails.EndDate, + // ContactPerson = projectDetails.ContactPerson, + // ProjectStatus = new StatusMaster + // { + // Id = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, + // Status = projectDetails.ProjectStatus?.Status, + // TenantId = tenantId + // } + // //ProjectStatusId = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, + //}; } if (projectVM == null) @@ -277,25 +382,6 @@ namespace MarcoBMS.Services.Controllers return Ok(ApiResponse.SuccessResponse(projectVM, "Project details fetched successfully", 200)); } - private ProjectVM? GetProjectViewModel(Project? project) - { - if (project == null) - { - return null; - } - return new ProjectVM - { - Id = project.Id, - Name = project.Name, - ShortName = project.ShortName, - StartDate = project.StartDate, - EndDate = project.EndDate, - ProjectStatus = project.ProjectStatus, - ContactPerson = project.ContactPerson, - ProjectAddress = project.ProjectAddress, - }; - } - [HttpGet("details-old/{id}")] public async Task DetailsOld([FromRoute] Guid id) { @@ -470,7 +556,7 @@ namespace MarcoBMS.Services.Controllers { // These operations do not depend on each other, so they can run in parallel. Task cacheAddDetailsTask = _cache.AddProjectDetails(project); - Task cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(ManageProject); + Task cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(PermissionsMaster.ManageProject); var notification = new { LoggedInUserId = loggedInUserId, Keyword = "Create_Project", Response = project.ToProjectDto() }; // Send notification only to the relevant group (e.g., users in the same tenant) @@ -762,7 +848,7 @@ namespace MarcoBMS.Services.Controllers var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 2: Check project-specific permission - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.ToString()); + var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); if (!hasProjectPermission) { _logger.LogWarning("Project access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); @@ -770,7 +856,7 @@ namespace MarcoBMS.Services.Controllers } // Step 3: Check 'ViewInfra' permission - var hasViewInfraPermission = await _permission.HasPermission(ViewInfra, loggedInEmployee.Id); + var hasViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); if (!hasViewInfraPermission) { _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); @@ -883,7 +969,7 @@ namespace MarcoBMS.Services.Controllers var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 2: Check if the employee has ViewInfra permission - var hasViewInfraPermission = await _permission.HasPermission(ViewInfra, loggedInEmployee.Id); + var hasViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); if (!hasViewInfraPermission) { _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index ae6264e..589ab52 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -1,7 +1,9 @@ using Marco.Pms.CacheHelper; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using MarcoBMS.Services.Service; +using Microsoft.EntityFrameworkCore; using Project = Marco.Pms.Model.Projects.Project; namespace Marco.Pms.Services.Helpers @@ -10,25 +12,407 @@ namespace Marco.Pms.Services.Helpers { private readonly ProjectCache _projectCache; private readonly EmployeeCache _employeeCache; + private readonly ReportCache _reportCache; private readonly ILoggingService _logger; + private readonly IDbContextFactory _dbContextFactory; - public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ILoggingService logger) + public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ReportCache reportCache, ILoggingService logger, + IDbContextFactory dbContextFactory) { _projectCache = projectCache; _employeeCache = employeeCache; + _reportCache = reportCache; _logger = logger; + _dbContextFactory = dbContextFactory; } - // ------------------------------------ Project Details and Infrastructure Cache --------------------------------------- + // ------------------------------------ Project Details Cache --------------------------------------- + // Assuming you have access to an IDbContextFactory as _dbContextFactory + // This is crucial for safe parallel database operations. + public async Task AddProjectDetails(Project project) { + // --- Step 1: Fetch all required data from the database in parallel --- + + // Each task uses its own DbContext instance to avoid concurrency issues. + var statusTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.StatusMasters + .AsNoTracking() + .Where(s => s.Id == project.ProjectStatusId) + .Select(s => new { s.Id, s.Status }) // Projection + .FirstOrDefaultAsync(); + }); + + var teamSizeTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.ProjectAllocations + .AsNoTracking() + .CountAsync(pa => pa.ProjectId == project.Id && pa.IsActive); // Server-side count is efficient + }); + + // This task fetches the entire infrastructure hierarchy and performs aggregations in the database. + var infrastructureTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + + // 1. Fetch all hierarchical data using projections. + // This is still a chain, but it's inside one task and much faster due to projections. + var buildings = await context.Buildings.AsNoTracking() + .Where(b => b.ProjectId == project.Id) + .Select(b => new { b.Id, b.ProjectId, b.Name, b.Description }) + .ToListAsync(); + var buildingIds = buildings.Select(b => b.Id).ToList(); + + var floors = await context.Floor.AsNoTracking() + .Where(f => buildingIds.Contains(f.BuildingId)) + .Select(f => new { f.Id, f.BuildingId, f.FloorName }) + .ToListAsync(); + var floorIds = floors.Select(f => f.Id).ToList(); + + var workAreas = await context.WorkAreas.AsNoTracking() + .Where(wa => floorIds.Contains(wa.FloorId)) + .Select(wa => new { wa.Id, wa.FloorId, wa.AreaName }) + .ToListAsync(); + var workAreaIds = workAreas.Select(wa => wa.Id).ToList(); + + // 2. THE KEY OPTIMIZATION: Aggregate work items in the database. + var workSummaries = await context.WorkItems.AsNoTracking() + .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) + .GroupBy(wi => wi.WorkAreaId) // Group by parent on the DB server + .Select(g => new // Let the DB do the SUM + { + WorkAreaId = g.Key, + PlannedWork = g.Sum(i => i.PlannedWork), + CompletedWork = g.Sum(i => i.CompletedWork) + }) + .ToDictionaryAsync(x => x.WorkAreaId); // Return a ready-to-use dictionary + + return (buildings, floors, workAreas, workSummaries); + }); + + // Wait for all parallel database operations to complete. + await Task.WhenAll(statusTask, teamSizeTask, infrastructureTask); + + // Get the results from the completed tasks. + var status = await statusTask; + var teamSize = await teamSizeTask; + var (allBuildings, allFloors, allWorkAreas, workSummariesByWorkAreaId) = await infrastructureTask; + + // --- Step 2: Process the fetched data and build the MongoDB model --- + + var projectDetails = new ProjectMongoDB + { + Id = project.Id.ToString(), + Name = project.Name, + ShortName = project.ShortName, + ProjectAddress = project.ProjectAddress, + StartDate = project.StartDate, + EndDate = project.EndDate, + ContactPerson = project.ContactPerson, + TeamSize = teamSize + }; + + projectDetails.ProjectStatus = new StatusMasterMongoDB + { + Id = status?.Id.ToString(), + Status = status?.Status + }; + + // Use fast in-memory lookups instead of .Where() in loops. + var floorsByBuildingId = allFloors.ToLookup(f => f.BuildingId); + var workAreasByFloorId = allWorkAreas.ToLookup(wa => wa.FloorId); + + double totalPlannedWork = 0, totalCompletedWork = 0; + var buildingMongoList = new List(); + + foreach (var building in allBuildings) + { + double buildingPlanned = 0, buildingCompleted = 0; + var floorMongoList = new List(); + + foreach (var floor in floorsByBuildingId[building.Id]) // Fast lookup + { + double floorPlanned = 0, floorCompleted = 0; + var workAreaMongoList = new List(); + + foreach (var wa in workAreasByFloorId[floor.Id]) // Fast lookup + { + // Get the pre-calculated summary from the dictionary. O(1) operation. + workSummariesByWorkAreaId.TryGetValue(wa.Id, out var summary); + var waPlanned = summary?.PlannedWork ?? 0; + var waCompleted = summary?.CompletedWork ?? 0; + + workAreaMongoList.Add(new WorkAreaMongoDB + { + Id = wa.Id.ToString(), + FloorId = wa.FloorId.ToString(), + AreaName = wa.AreaName, + PlannedWork = waPlanned, + CompletedWork = waCompleted + }); + + floorPlanned += waPlanned; + floorCompleted += waCompleted; + } + + floorMongoList.Add(new FloorMongoDB + { + Id = floor.Id.ToString(), + BuildingId = floor.BuildingId.ToString(), + FloorName = floor.FloorName, + PlannedWork = floorPlanned, + CompletedWork = floorCompleted, + WorkAreas = workAreaMongoList + }); + + buildingPlanned += floorPlanned; + buildingCompleted += floorCompleted; + } + + buildingMongoList.Add(new BuildingMongoDB + { + Id = building.Id.ToString(), + ProjectId = building.ProjectId.ToString(), + BuildingName = building.Name, + Description = building.Description, + PlannedWork = buildingPlanned, + CompletedWork = buildingCompleted, + Floors = floorMongoList + }); + + totalPlannedWork += buildingPlanned; + totalCompletedWork += buildingCompleted; + } + + projectDetails.Buildings = buildingMongoList; + projectDetails.PlannedWork = totalPlannedWork; + projectDetails.CompletedWork = totalCompletedWork; + try { - await _projectCache.AddProjectDetailsToCache(project); + await _projectCache.AddProjectDetailsToCache(projectDetails); } catch (Exception ex) { - _logger.LogWarning("Error occured while adding project {ProjectId} to Cache : {Error}", project.Id, ex.Message); + _logger.LogWarning("Error occurred while adding project {ProjectId} to Cache: {Error}", project.Id, ex.Message); + } + } + public async Task AddProjectDetailsList(List projects) + { + var projectIds = projects.Select(p => p.Id).ToList(); + if (!projectIds.Any()) + { + return; // Nothing to do + } + var projectStatusIds = projects.Select(p => p.ProjectStatusId).Distinct().ToList(); + + // --- Step 1: Fetch all required data in maximum parallel --- + // Each task uses its own DbContext and selects only the required columns (projection). + + var statusTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.StatusMasters + .AsNoTracking() + .Where(s => projectStatusIds.Contains(s.Id)) + .Select(s => new { s.Id, s.Status }) // Projection + .ToDictionaryAsync(s => s.Id); + }); + + var teamSizeTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + // Server-side aggregation and projection into a dictionary + return await context.ProjectAllocations + .AsNoTracking() + .Where(pa => projectIds.Contains(pa.ProjectId) && pa.IsActive) + .GroupBy(pa => pa.ProjectId) + .Select(g => new { ProjectId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ProjectId, x => x.Count); + }); + + var buildingsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Buildings + .AsNoTracking() + .Where(b => projectIds.Contains(b.ProjectId)) + .Select(b => new { b.Id, b.ProjectId, b.Name, b.Description }) // Projection + .ToListAsync(); + }); + + // We need the building IDs for the next level, so we must await this one first. + var allBuildings = await buildingsTask; + var buildingIds = allBuildings.Select(b => b.Id).ToList(); + + var floorsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Floor + .AsNoTracking() + .Where(f => buildingIds.Contains(f.BuildingId)) + .Select(f => new { f.Id, f.BuildingId, f.FloorName }) // Projection + .ToListAsync(); + }); + + // We need floor IDs for the next level. + var allFloors = await floorsTask; + var floorIds = allFloors.Select(f => f.Id).ToList(); + + var workAreasTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.WorkAreas + .AsNoTracking() + .Where(wa => floorIds.Contains(wa.FloorId)) + .Select(wa => new { wa.Id, wa.FloorId, wa.AreaName }) // Projection + .ToListAsync(); + }); + + // The most powerful optimization: Aggregate work items in the database. + var workSummaryTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + var workAreaIds = await context.WorkAreas + .Where(wa => floorIds.Contains(wa.FloorId)) + .Select(wa => wa.Id) + .ToListAsync(); + + // Let the DB do the SUM. This is much faster and transfers less data. + return await context.WorkItems + .AsNoTracking() + .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) + .GroupBy(wi => wi.WorkAreaId) + .Select(g => new + { + WorkAreaId = g.Key, + PlannedWork = g.Sum(wi => wi.PlannedWork), + CompletedWork = g.Sum(wi => wi.CompletedWork) + }) + .ToDictionaryAsync(x => x.WorkAreaId); + }); + + // Await the remaining parallel tasks. + await Task.WhenAll(statusTask, teamSizeTask, workAreasTask, workSummaryTask); + + // --- Step 2: Process the fetched data and build the MongoDB models --- + + var allStatuses = await statusTask; + var teamSizesByProjectId = await teamSizeTask; + var allWorkAreas = await workAreasTask; + var workSummariesByWorkAreaId = await workSummaryTask; + + // Create fast in-memory lookups for hierarchical data + var buildingsByProjectId = allBuildings.ToLookup(b => b.ProjectId); + var floorsByBuildingId = allFloors.ToLookup(f => f.BuildingId); + var workAreasByFloorId = allWorkAreas.ToLookup(wa => wa.FloorId); + + var projectDetailsList = new List(projects.Count); + foreach (var project in projects) + { + var projectDetails = new ProjectMongoDB + { + Id = project.Id.ToString(), + Name = project.Name, + ShortName = project.ShortName, + ProjectAddress = project.ProjectAddress, + StartDate = project.StartDate, + EndDate = project.EndDate, + ContactPerson = project.ContactPerson, + TeamSize = teamSizesByProjectId.GetValueOrDefault(project.Id, 0) + }; + + if (allStatuses.TryGetValue(project.ProjectStatusId, out var status)) + { + projectDetails.ProjectStatus = new StatusMasterMongoDB + { + Id = status.Id.ToString(), + Status = status.Status + }; + } + + double totalPlannedWork = 0, totalCompletedWork = 0; + var buildingMongoList = new List(); + + foreach (var building in buildingsByProjectId[project.Id]) + { + double buildingPlanned = 0, buildingCompleted = 0; + var floorMongoList = new List(); + + foreach (var floor in floorsByBuildingId[building.Id]) + { + double floorPlanned = 0, floorCompleted = 0; + var workAreaMongoList = new List(); + + foreach (var wa in workAreasByFloorId[floor.Id]) + { + double waPlanned = 0, waCompleted = 0; + if (workSummariesByWorkAreaId.TryGetValue(wa.Id, out var summary)) + { + waPlanned = summary.PlannedWork; + waCompleted = summary.CompletedWork; + } + + workAreaMongoList.Add(new WorkAreaMongoDB + { + Id = wa.Id.ToString(), + FloorId = wa.FloorId.ToString(), + AreaName = wa.AreaName, + PlannedWork = waPlanned, + CompletedWork = waCompleted + }); + + floorPlanned += waPlanned; + floorCompleted += waCompleted; + } + + floorMongoList.Add(new FloorMongoDB + { + Id = floor.Id.ToString(), + BuildingId = floor.BuildingId.ToString(), + FloorName = floor.FloorName, + PlannedWork = floorPlanned, + CompletedWork = floorCompleted, + WorkAreas = workAreaMongoList + }); + + buildingPlanned += floorPlanned; + buildingCompleted += floorCompleted; + } + + buildingMongoList.Add(new BuildingMongoDB + { + Id = building.Id.ToString(), + ProjectId = building.ProjectId.ToString(), + BuildingName = building.Name, + Description = building.Description, + PlannedWork = buildingPlanned, + CompletedWork = buildingCompleted, + Floors = floorMongoList + }); + + totalPlannedWork += buildingPlanned; + totalCompletedWork += buildingCompleted; + } + + projectDetails.Buildings = buildingMongoList; + projectDetails.PlannedWork = totalPlannedWork; + projectDetails.CompletedWork = totalCompletedWork; + + projectDetailsList.Add(projectDetails); + } + + // --- Step 3: Update the cache --- + try + { + await _projectCache.AddProjectDetailsListToCache(projectDetailsList); + } + catch (Exception ex) + { + _logger.LogWarning("Error occurred while adding project list to Cache: {Error}", ex.Message); } } public async Task UpdateProjectDetailsOnly(Project project) @@ -62,7 +446,14 @@ namespace Marco.Pms.Services.Helpers try { var response = await _projectCache.GetProjectDetailsListFromCache(projectIds); - return response; + if (response.Any()) + { + return response; + } + else + { + return null; + } } catch (Exception ex) { @@ -70,6 +461,9 @@ namespace Marco.Pms.Services.Helpers return null; } } + + // ------------------------------------ Project Infrastructure Cache --------------------------------------- + public async Task AddBuildngInfra(Guid projectId, Building? building = null, Floor? floor = null, WorkArea? workArea = null, Guid? buildingId = null) { try @@ -342,5 +736,33 @@ namespace Marco.Pms.Services.Helpers _logger.LogWarning("Error occured while deleting Application role {RoleId} from Cache for employee {EmployeeId}: {Error}", roleId, employeeId, ex.Message); } } + + + // ------------------------------------ Report Cache --------------------------------------- + + public async Task?> GetProjectReportMail(bool IsSend) + { + try + { + var response = await _reportCache.GetProjectReportMailFromCache(IsSend); + return response; + } + catch (Exception ex) + { + _logger.LogError("Error occured while fetching project report mail bodys: {Error}", ex.Message); + return null; + } + } + public async Task AddProjectReportMail(ProjectReportEmailMongoDB report) + { + try + { + await _reportCache.AddProjectReportMailToCache(report); + } + catch (Exception ex) + { + _logger.LogError("Error occured while adding project report mail bodys: {Error}", ex.Message); + } + } } } diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs index 85003ae..fb5b6f2 100644 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ b/Marco.Pms.Services/Helpers/ProjectsHelper.cs @@ -1,9 +1,9 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; -using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using Marco.Pms.Services.Helpers; +using Marco.Pms.Services.Service; using Microsoft.EntityFrameworkCore; namespace MarcoBMS.Services.Helpers @@ -13,13 +13,14 @@ namespace MarcoBMS.Services.Helpers private readonly ApplicationDbContext _context; private readonly RolesHelper _rolesHelper; private readonly CacheUpdateHelper _cache; + private readonly PermissionServices _permission; - - public ProjectsHelper(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache) + public ProjectsHelper(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache, PermissionServices permission) { _context = context; _rolesHelper = rolesHelper; _cache = cache; + _permission = permission; } public async Task> GetAllProjectByTanentID(Guid tanentID) @@ -51,80 +52,32 @@ namespace MarcoBMS.Services.Helpers } } - public async Task> GetMyProjects(Guid tenantId, Employee LoggedInEmployee) + public async Task> GetMyProjects(Guid tenantId, Employee LoggedInEmployee) { - string[] projectsId = []; - List projects = new List(); - var projectIds = await _cache.GetProjects(LoggedInEmployee.Id); - if (projectIds != null) + if (projectIds == null) { - - List projectdetails = await _cache.GetProjectDetailsList(projectIds) ?? new List(); - projects = projectdetails.Select(p => new Project + var hasPermission = await _permission.HasPermission(LoggedInEmployee.Id, PermissionsMaster.ManageProject); + if (hasPermission) { - Id = Guid.Parse(p.Id), - Name = p.Name, - ShortName = p.ShortName, - ProjectAddress = p.ProjectAddress, - ProjectStatusId = Guid.Parse(p.ProjectStatus?.Id ?? ""), - ContactPerson = p.ContactPerson, - StartDate = p.StartDate, - EndDate = p.EndDate, - TenantId = tenantId - }).ToList(); - - if (projects.Count != projectIds.Count) - { - projects = await _context.Projects.Where(p => projectIds.Contains(p.Id)).ToListAsync(); - } - } - else - { - var featurePermissionIds = await _cache.GetPermissions(LoggedInEmployee.Id); - if (featurePermissionIds == null) - { - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(LoggedInEmployee.Id); - featurePermissionIds = featurePermission.Select(fp => fp.Id).ToList(); - } - // Define a common queryable base for projects - IQueryable projectQuery = _context.Projects.Where(c => c.TenantId == tenantId); - - // 2. Optimized Project Retrieval Logic - // User with permission 'manage project' can see all projects - if (featurePermissionIds != null && featurePermissionIds.Contains(Guid.Parse("172fc9b6-755b-4f62-ab26-55c34a330614"))) - { - // If GetAllProjectByTanentID is already optimized and directly returns IQueryable or - // directly executes with ToListAsync(), keep it. - // If it does more complex logic or extra trips, consider inlining here. - projects = await projectQuery.ToListAsync(); // Directly query the context + var projects = await _context.Projects.Where(c => c.TenantId == tenantId).ToListAsync(); + projectIds = projects.Select(p => p.Id).ToList(); } else { - // 3. Efficiently get project allocations and then filter projects - // Load allocations only once var allocation = await GetProjectByEmployeeID(LoggedInEmployee.Id); - - // If there are no allocations, return an empty list early - if (allocation == null || !allocation.Any()) + if (allocation.Any()) { - return new List(); + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); } - - // Use LINQ's Contains for efficient filtering by ProjectId - projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); // Get distinct Guids - - // Filter projects based on the retrieved ProjectIds - projects = await projectQuery.Where(c => projectIds.Contains(c.Id)).ToListAsync(); - + return new List(); } - projectIds = projects.Select(p => p.Id).ToList(); await _cache.AddProjects(LoggedInEmployee.Id, projectIds); } - return projects; + return projectIds; } } -} +} \ No newline at end of file diff --git a/Marco.Pms.Services/Helpers/ReportHelper.cs b/Marco.Pms.Services/Helpers/ReportHelper.cs index e7632fd..4ec0978 100644 --- a/Marco.Pms.Services/Helpers/ReportHelper.cs +++ b/Marco.Pms.Services/Helpers/ReportHelper.cs @@ -1,20 +1,28 @@ -using System.Globalization; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Attendance; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Mail; using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Report; +using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; +using System.Globalization; namespace Marco.Pms.Services.Helpers { public class ReportHelper { private readonly ApplicationDbContext _context; + private readonly IEmailSender _emailSender; + private readonly ILoggingService _logger; private readonly CacheUpdateHelper _cache; - public ReportHelper(CacheUpdateHelper cache, ApplicationDbContext context) + public ReportHelper(ApplicationDbContext context, IEmailSender emailSender, ILoggingService logger, CacheUpdateHelper cache) { - _cache = cache; _context = context; + _emailSender = emailSender; + _logger = logger; + _cache = cache; } public async Task GetDailyProjectReport(Guid projectId, Guid tenantId) { @@ -270,5 +278,88 @@ namespace Marco.Pms.Services.Helpers } return null; } + /// + /// Retrieves project statistics for a given project ID and sends an email report. + /// + /// The ID of the project. + /// The email address of the recipient. + /// An ApiResponse indicating the success or failure of retrieving statistics and sending the email. + public async Task> GetProjectStatistics(Guid projectId, List recipientEmails, string body, string subject, Guid tenantId) + { + // --- Input Validation --- + if (projectId == Guid.Empty) + { + _logger.LogError("Validation Error: Provided empty project ID while fetching project report."); + return ApiResponse.ErrorResponse("Provided empty Project ID.", "Provided empty Project ID.", 400); + } + + if (recipientEmails == null || !recipientEmails.Any()) + { + _logger.LogError("Validation Error: No recipient emails provided for project ID {ProjectId}.", projectId); + return ApiResponse.ErrorResponse("No recipient emails provided.", "No recipient emails provided.", 400); + } + + // --- Fetch Project Statistics --- + var statisticReport = await GetDailyProjectReport(projectId, tenantId); + + if (statisticReport == null) + { + _logger.LogWarning("Project Data Not Found: User attempted to fetch project progress for project ID {ProjectId} but it was not found.", projectId); + return ApiResponse.ErrorResponse("Project not found.", "Project not found.", 404); + } + + // --- Send Email & Log --- + string emailBody; + try + { + emailBody = await _emailSender.SendProjectStatisticsEmail(recipientEmails, body, subject, statisticReport); + } + catch (Exception ex) + { + _logger.LogError("Email Sending Error: Failed to send project statistics email for project ID {ProjectId}. : {Error}", projectId, ex.Message); + return ApiResponse.ErrorResponse("Failed to send email.", "An error occurred while sending the email.", 500); + } + + // Find a relevant employee. Use AsNoTracking() for read-only query if the entity won't be modified. + // Consider if you need *any* employee from the recipients or a specific one (e.g., the sender). + var employee = await _context.Employees + .AsNoTracking() // Optimize for read-only + .FirstOrDefaultAsync(e => e.Email != null && recipientEmails.Contains(e.Email)) ?? new Employee(); + + // Initialize Employee to a default or null, based on whether an employee is always expected. + // If employee.Id is a non-nullable type, ensure proper handling if employee is null. + Guid employeeId = employee.Id; // Default to Guid.Empty if no employee found + + var mailLogs = recipientEmails.Select(recipientEmail => new MailLog + { + ProjectId = projectId, + EmailId = recipientEmail, + Body = emailBody, + EmployeeId = employeeId, // Use the determined employeeId + TimeStamp = DateTime.UtcNow, + TenantId = tenantId + }).ToList(); + + _context.MailLogs.AddRange(mailLogs); + + try + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully sent and logged project statistics email for Project ID {ProjectId} to {RecipientCount} recipients.", projectId, recipientEmails.Count); + return ApiResponse.SuccessResponse(statisticReport, "Email sent successfully", 200); + } + catch (DbUpdateException dbEx) + { + _logger.LogError("Database Error: Failed to save mail logs for project ID {ProjectId}. : {Error}", projectId, dbEx.Message); + // Depending on your requirements, you might still return success here as the email was sent. + // Or return an error indicating the logging failed. + return ApiResponse.ErrorResponse("Email sent, but failed to log activity.", "Email sent, but an error occurred while logging.", 500); + } + catch (Exception ex) + { + _logger.LogError("Unexpected Error: An unhandled exception occurred while processing project statistics for project ID {ProjectId}. : {Error}", projectId, ex.Message); + return ApiResponse.ErrorResponse("An unexpected error occurred.", "An unexpected error occurred.", 500); + } + } } } diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs new file mode 100644 index 0000000..c7ec4af --- /dev/null +++ b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs @@ -0,0 +1,30 @@ +using AutoMapper; +using Marco.Pms.Model.Master; +using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.Projects; +using Marco.Pms.Model.ViewModels.Projects; + +namespace Marco.Pms.Services.MappingProfiles +{ + public class ProjectMappingProfile : Profile + { + public ProjectMappingProfile() + { + // Your mappings + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap() + .ForMember( + dest => dest.Id, + // Explicitly and safely convert string Id to Guid Id + opt => opt.MapFrom(src => src.Id == null ? Guid.Empty : new Guid(src.Id)) + ); + + CreateMap(); + CreateMap(); + } + } +} diff --git a/Marco.Pms.Services/Marco.Pms.Services.csproj b/Marco.Pms.Services/Marco.Pms.Services.csproj index a235e6a..2feafaf 100644 --- a/Marco.Pms.Services/Marco.Pms.Services.csproj +++ b/Marco.Pms.Services/Marco.Pms.Services.csproj @@ -11,6 +11,7 @@ + diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 30831c6..7fa2647 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -1,4 +1,3 @@ -using System.Text; using Marco.Pms.CacheHelper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Authentication; @@ -16,47 +15,23 @@ using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using Serilog; - +using System.Text; var builder = WebApplication.CreateBuilder(args); -// Add Serilog Configuration -string? mongoConn = builder.Configuration["MongoDB:SerilogDatabaseUrl"]; -string timeString = "00:00:30"; -TimeSpan.TryParse(timeString, out TimeSpan timeSpan); +#region ======================= Service Configuration (Dependency Injection) ======================= -// Add Serilog Configuration +#region Logging builder.Host.UseSerilog((context, config) => { - config.ReadFrom.Configuration(context.Configuration) // Taking all configuration from appsetting.json - .WriteTo.MongoDB( - databaseUrl: mongoConn ?? string.Empty, - collectionName: "api-logs", - batchPostingLimit: 100, - period: timeSpan - ); - + config.ReadFrom.Configuration(context.Configuration); }); +#endregion -// Add services -var corsSettings = builder.Configuration.GetSection("Cors"); -var allowedOrigins = corsSettings.GetValue("AllowedOrigins")?.Split(','); -var allowedMethods = corsSettings.GetValue("AllowedMethods")?.Split(','); -var allowedHeaders = corsSettings.GetValue("AllowedHeaders")?.Split(','); - +#region CORS (Cross-Origin Resource Sharing) builder.Services.AddCors(options => { - options.AddPolicy("Policy", policy => - { - if (allowedOrigins != null && allowedMethods != null && allowedHeaders != null) - { - policy.WithOrigins(allowedOrigins) - .WithMethods(allowedMethods) - .WithHeaders(allowedHeaders); - } - }); -}).AddCors(options => -{ + // A more permissive policy for development options.AddPolicy("DevCorsPolicy", policy => { policy.AllowAnyOrigin() @@ -64,93 +39,51 @@ builder.Services.AddCors(options => .AllowAnyHeader() .WithExposedHeaders("Authorization"); }); -}); -// Add services to the container. -builder.Services.AddHostedService(); + // A stricter policy for production (loaded from config) + var corsSettings = builder.Configuration.GetSection("Cors"); + var allowedOrigins = corsSettings.GetValue("AllowedOrigins")?.Split(',') ?? Array.Empty(); + options.AddPolicy("ProdCorsPolicy", policy => + { + policy.WithOrigins(allowedOrigins) + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); +#endregion + +#region Core Web & Framework Services builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddSignalR(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -builder.Services.AddSwaggerGen(option => -{ - option.SwaggerDoc("v1", new OpenApiInfo { Title = "Demo API", Version = "v1" }); - option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { - In = ParameterLocation.Header, - Description = "Please enter a valid token", - Name = "Authorization", - Type = SecuritySchemeType.Http, - BearerFormat = "JWT", - Scheme = "Bearer" - }); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddMemoryCache(); +builder.Services.AddAutoMapper(typeof(Program)); +builder.Services.AddHostedService(); +#endregion - option.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type=ReferenceType.SecurityScheme, - Id="Bearer" - } - }, - new string[]{} - } - }); -}); +#region Database & Identity +string? connString = builder.Configuration.GetConnectionString("DefaultConnectionString") + ?? throw new InvalidOperationException("Database connection string 'DefaultConnectionString' not found."); -builder.Services.Configure(builder.Configuration.GetSection("SmtpSettings")); -builder.Services.AddTransient(); - -builder.Services.Configure(builder.Configuration.GetSection("AWS")); // For uploading images to aws s3 -builder.Services.AddTransient(); - -builder.Services.AddIdentity().AddEntityFrameworkStores().AddDefaultTokenProviders(); - - -string? connString = builder.Configuration.GetConnectionString("DefaultConnectionString"); +// This single call correctly registers BOTH the DbContext (scoped) AND the IDbContextFactory (singleton). +builder.Services.AddDbContextFactory(options => + options.UseMySql(connString, ServerVersion.AutoDetect(connString))); builder.Services.AddDbContext(options => -{ - options.UseMySql(connString, ServerVersion.AutoDetect(connString)); -}); + options.UseMySql(connString, ServerVersion.AutoDetect(connString))); +builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); +#endregion -builder.Services.AddMemoryCache(); - - -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); - - -builder.Services.AddHttpContextAccessor(); - +#region Authentication (JWT) var jwtSettings = builder.Configuration.GetSection("Jwt").Get() ?? throw new InvalidOperationException("JwtSettings section is missing or invalid."); - if (jwtSettings != null && jwtSettings.Key != null) { + builder.Services.AddSingleton(jwtSettings); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -168,71 +101,129 @@ if (jwtSettings != null && jwtSettings.Key != null) ValidAudience = jwtSettings.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Key)) }; - + // This event allows SignalR to get the token from the query string options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; - var path = context.HttpContext.Request.Path; - - // Match your hub route here - if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs/marco")) + if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs/marco")) { context.Token = accessToken; } - return Task.CompletedTask; } }; }); - builder.Services.AddSingleton(jwtSettings); } +#endregion -builder.Services.AddSignalR(); +#region API Documentation (Swagger) +builder.Services.AddSwaggerGen(option => +{ + option.SwaggerDoc("v1", new OpenApiInfo { Title = "Marco PMS API", Version = "v1" }); + option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Please enter a valid token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + option.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } + }, + Array.Empty() + } + }); +}); +#endregion + +#region Application-Specific Services +// Configuration-bound services +builder.Services.Configure(builder.Configuration.GetSection("SmtpSettings")); +builder.Services.Configure(builder.Configuration.GetSection("AWS")); + +// Transient services (lightweight, created each time) +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +// Scoped services (one instance per HTTP request) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Singleton services (one instance for the app's lifetime) +builder.Services.AddSingleton(); +#endregion + +#region Web Server (Kestrel) builder.WebHost.ConfigureKestrel(options => { - options.AddServerHeader = false; // Disable the "Server" header + options.AddServerHeader = false; // Disable the "Server" header for security }); +#endregion + +#endregion var app = builder.Build(); +#region ===================== HTTP Request Pipeline Configuration ===================== + +// The order of middleware registration is critical for correct application behavior. + +#region Global Middleware (Run First) +// These custom middleware components run at the beginning of the pipeline to handle cross-cutting concerns. app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); +#endregion - - -// Configure the HTTP request pipeline. +#region Development Environment Configuration +// These tools are only enabled in the Development environment for debugging and API testing. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); - // Use CORS in the pipeline - app.UseCors("DevCorsPolicy"); } -else -{ - //if (app.Environment.IsProduction()) - //{ - // app.UseCors("ProdCorsPolicy"); - //} +#endregion - //app.UseCors("AllowAll"); - app.UseCors("DevCorsPolicy"); -} +#region Standard Middleware +// Common middleware for handling static content, security, and routing. +app.UseStaticFiles(); // Enables serving static files (e.g., from wwwroot) +app.UseHttpsRedirection(); // Redirects HTTP requests to HTTPS +#endregion -app.UseStaticFiles(); // Enables serving static files +#region Security (CORS, Authentication & Authorization) +// Security-related middleware must be in the correct order. +var corsPolicy = app.Environment.IsDevelopment() ? "DevCorsPolicy" : "ProdCorsPolicy"; +app.UseCors(corsPolicy); // CORS must be applied before Authentication/Authorization. -//app.UseSerilogRequestLogging(); // This is Default Serilog Logging Middleware we are not using this because we're using custom logging middleware +app.UseAuthentication(); // 1. Identifies who the user is. +app.UseAuthorization(); // 2. Determines what the identified user is allowed to do. +#endregion - -app.UseHttpsRedirection(); - - -app.UseAuthentication(); -app.UseAuthorization(); -app.MapHub("/hubs/marco"); +#region Endpoint Routing (Run Last) +// These map incoming requests to the correct controller actions or SignalR hubs. app.MapControllers(); +app.MapHub("/hubs/marco"); +#endregion -app.Run(); +#endregion + +app.Run(); \ No newline at end of file diff --git a/Marco.Pms.Services/Service/ILoggingService.cs b/Marco.Pms.Services/Service/ILoggingService.cs index 39dbb00..b835d0c 100644 --- a/Marco.Pms.Services/Service/ILoggingService.cs +++ b/Marco.Pms.Services/Service/ILoggingService.cs @@ -1,10 +1,9 @@ -using Serilog.Context; - -namespace MarcoBMS.Services.Service +namespace MarcoBMS.Services.Service { public interface ILoggingService { void LogInfo(string? message, params object[]? args); + void LogDebug(string? message, params object[]? args); void LogWarning(string? message, params object[]? args); void LogError(string? message, params object[]? args); diff --git a/Marco.Pms.Services/Service/LoggingServices.cs b/Marco.Pms.Services/Service/LoggingServices.cs index 4328a2a..5a016de 100644 --- a/Marco.Pms.Services/Service/LoggingServices.cs +++ b/Marco.Pms.Services/Service/LoggingServices.cs @@ -18,10 +18,11 @@ namespace MarcoBMS.Services.Service { _logger.LogError(message, args); } - else { + else + { _logger.LogError(message); } - } + } public void LogInfo(string? message, params object[]? args) { @@ -35,6 +36,18 @@ namespace MarcoBMS.Services.Service _logger.LogInformation(message); } } + public void LogDebug(string? message, params object[]? args) + { + using (LogContext.PushProperty("LogLevel", "Information")) + if (args != null) + { + _logger.LogDebug(message, args); + } + else + { + _logger.LogDebug(message); + } + } public void LogWarning(string? message, params object[]? args) { @@ -49,6 +62,5 @@ namespace MarcoBMS.Services.Service } } } - } diff --git a/Marco.Pms.Services/Service/PermissionServices.cs b/Marco.Pms.Services/Service/PermissionServices.cs index ce7476b..7162dc5 100644 --- a/Marco.Pms.Services/Service/PermissionServices.cs +++ b/Marco.Pms.Services/Service/PermissionServices.cs @@ -1,7 +1,6 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; -using Marco.Pms.Model.Projects; using Marco.Pms.Services.Helpers; using MarcoBMS.Services.Helpers; using Microsoft.EntityFrameworkCore; @@ -12,13 +11,11 @@ namespace Marco.Pms.Services.Service { private readonly ApplicationDbContext _context; private readonly RolesHelper _rolesHelper; - private readonly ProjectsHelper _projectsHelper; private readonly CacheUpdateHelper _cache; - public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, ProjectsHelper projectsHelper, CacheUpdateHelper cache) + public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache) { _context = context; _rolesHelper = rolesHelper; - _projectsHelper = projectsHelper; _cache = cache; } @@ -33,24 +30,31 @@ namespace Marco.Pms.Services.Service var hasPermission = featurePermissionIds.Contains(featurePermissionId); return hasPermission; } - public async Task HasProjectPermission(Employee emp, string projectId) + public async Task HasProjectPermission(Employee LoggedInEmployee, Guid projectId) { - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(emp.Id); - string[] projectsId = []; + var employeeId = LoggedInEmployee.Id; + var projectIds = await _cache.GetProjects(employeeId); - /* User with permission manage project can see all projects */ - if (featurePermission != null && featurePermission.Exists(c => c.Id.ToString() == "172fc9b6-755b-4f62-ab26-55c34a330614")) + if (projectIds == null) { - List projects = await _projectsHelper.GetAllProjectByTanentID(emp.TenantId); - projectsId = projects.Select(c => c.Id.ToString()).ToArray(); + var hasPermission = await HasPermission(employeeId, PermissionsMaster.ManageProject); + if (hasPermission) + { + var projects = await _context.Projects.Where(c => c.TenantId == LoggedInEmployee.TenantId).ToListAsync(); + projectIds = projects.Select(p => p.Id).ToList(); + } + else + { + var allocation = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive == true).ToListAsync(); + if (allocation.Any()) + { + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); + } + return false; + } + await _cache.AddProjects(LoggedInEmployee.Id, projectIds); } - else - { - List allocation = await _projectsHelper.GetProjectByEmployeeID(emp.Id); - projectsId = allocation.Select(c => c.ProjectId.ToString()).ToArray(); - } - bool response = projectsId.Contains(projectId); - return response; + return projectIds.Contains(projectId); } } } -- 2.43.0 From c359212ee5753b5c0972bc27635625f19cf55094 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 14 Jul 2025 12:02:45 +0530 Subject: [PATCH 27/50] Optimized both get Project list API and get Project list basic API --- Marco.Pms.CacheHelper/ProjectCache.cs | 43 +- .../Controllers/ProjectController.cs | 727 +++++++++--------- .../Controllers/UserController.cs | 2 +- .../Helpers/CacheUpdateHelper.cs | 43 +- Marco.Pms.Services/Helpers/ProjectsHelper.cs | 2 +- Marco.Pms.Services/Helpers/RolesHelper.cs | 121 ++- .../Service/PermissionServices.cs | 2 +- 7 files changed, 513 insertions(+), 427 deletions(-) diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index 1fd36f4..183bbc4 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -32,20 +32,9 @@ namespace Marco.Pms.CacheHelper public async Task AddProjectDetailsListToCache(List projectDetailsList) { await _projetCollection.InsertManyAsync(projectDetailsList); - //_logger.LogInfo("[AddProjectDetails] Project details inserted in MongoDB for ProjectId: {ProjectId}", project.Id); } - public async Task UpdateProjectDetailsOnlyToCache(Project project) + public async Task UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus) { - //_logger.LogInfo("Starting update for project: {ProjectId}", project.Id); - - var projectStatus = await _context.StatusMasters - .FirstOrDefaultAsync(s => s.Id == project.ProjectStatusId); - - if (projectStatus == null) - { - //_logger.LogWarning("StatusMaster not found for ProjectStatusId: {StatusId}", project.ProjectStatusId); - } - // Build the update definition var updates = Builders.Update.Combine( Builders.Update.Set(r => r.Name, project.Name), @@ -69,11 +58,9 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("No project matched in MongoDB for update. ProjectId: {ProjectId}", project.Id); return false; } - //_logger.LogInfo("Project {ProjectId} successfully updated in MongoDB", project.Id); return true; } public async Task GetProjectDetailsFromCache(Guid projectId) @@ -83,21 +70,12 @@ namespace Marco.Pms.CacheHelper var filter = Builders.Filter.Eq(p => p.Id, projectId.ToString()); var projection = Builders.Projection.Exclude(p => p.Buildings); - //_logger.LogInfo("Fetching project details for ProjectId: {ProjectId} from MongoDB", projectId); - // Perform query var project = await _projetCollection .Find(filter) .Project(projection) .FirstOrDefaultAsync(); - if (project == null) - { - //_logger.LogWarning("No project found in MongoDB for ProjectId: {ProjectId}", projectId); - return null; - } - - //_logger.LogInfo("Successfully fetched project details (excluding Buildings) for ProjectId: {ProjectId}", projectId); return project; } public async Task> GetProjectDetailsListFromCache(List projectIds) @@ -111,6 +89,12 @@ namespace Marco.Pms.CacheHelper .ToListAsync(); return projects; } + public async Task DeleteProjectByIdFromCacheAsync(Guid projectId) + { + var filter = Builders.Filter.Eq(e => e.Id, projectId.ToString()); + var result = await _projetCollection.DeleteOneAsync(filter); + return result.DeletedCount > 0; + } // ------------------------------------------------------- Project InfraStructure ------------------------------------------------------- @@ -407,6 +391,10 @@ namespace Marco.Pms.CacheHelper return null; return result; } + + + // ------------------------------------------------------- WorkItem ------------------------------------------------------- + public async Task> GetWorkItemsByWorkAreaIdsFromCache(List workAreaIds) { var stringWorkAreaIds = workAreaIds.Select(wa => wa.ToString()).ToList(); @@ -418,9 +406,6 @@ namespace Marco.Pms.CacheHelper return workItems; } - - // ------------------------------------------------------- WorkItem ------------------------------------------------------- - public async Task ManageWorkItemDetailsToCache(List workItems) { var activityIds = workItems.Select(wi => wi.ActivityId).ToList(); @@ -510,5 +495,11 @@ namespace Marco.Pms.CacheHelper } return false; } + public async Task DeleteWorkItemByIdFromCacheAsync(Guid workItemId) + { + var filter = Builders.Filter.Eq(e => e.Id, workItemId.ToString()); + var result = await _taskCollection.DeleteOneAsync(filter); + return result.DeletedCount > 0; + } } } diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 29f9d04..adb5887 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -28,10 +28,10 @@ namespace MarcoBMS.Services.Controllers [Authorize] public class ProjectController : ControllerBase { + private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly ILoggingService _logger; - //private readonly RolesHelper _rolesHelper; private readonly ProjectsHelper _projectsHelper; private readonly IHubContext _signalR; private readonly PermissionServices _permission; @@ -40,13 +40,13 @@ namespace MarcoBMS.Services.Controllers private readonly Guid tenantId; - public ProjectController(ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, RolesHelper rolesHelper, ProjectsHelper projectHelper, - IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IMapper mapper) + public ProjectController(IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, RolesHelper rolesHelper, + ProjectsHelper projectHelper, IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IMapper mapper) { + _dbContextFactory = dbContextFactory; _context = context; _userHelper = userHelper; _logger = logger; - //_rolesHelper = rolesHelper; _projectsHelper = projectHelper; _signalR = signalR; _cache = cache; @@ -55,55 +55,10 @@ namespace MarcoBMS.Services.Controllers tenantId = _userHelper.GetTenantId(); } - [HttpGet("list/basic1")] - public async Task GetAllProjects1() - { - if (!ModelState.IsValid) - { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - - } - Guid tenantId = _userHelper.GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // Defensive check for null employee (important for robust APIs) - if (LoggedInEmployee == null) - { - return Unauthorized(ApiResponse.ErrorResponse("Employee not found.", null, 401)); - } - - List response = new List(); - List projectIds = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - - List? projectsDetails = await _cache.GetProjectDetailsList(projectIds); - if (projectsDetails == null) - { - List projects = await _context.Projects.Where(p => projectIds.Contains(p.Id)).ToListAsync(); - //using (var scope = _serviceScopeFactory.CreateScope()) - //{ - // var cacheHelper = scope.ServiceProvider.GetRequiredService(); - - //} - foreach (var project in projects) - { - await _cache.AddProjectDetails(project); - } - response = projects.Select(p => _mapper.Map(p)).ToList(); - } - else - { - response = projectsDetails.Select(p => _mapper.Map(p)).ToList(); - } - - return Ok(ApiResponse.SuccessResponse(response, "Success.", 200)); - } + #region =================================================================== Project Get APIs =================================================================== [HttpGet("list/basic")] - public async Task GetAllProjects() // Renamed for clarity + public async Task GetAllProjectsBasic() { // Step 1: Get the current user var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); @@ -133,146 +88,82 @@ namespace MarcoBMS.Services.Controllers } /// - /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. - /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the - /// database (as Project), updates the cache, and returns a unified list of ViewModels. + /// Retrieves a list of projects accessible to the current user, including aggregated details. + /// This method is optimized to use a cache-first approach. If data is not in the cache, + /// it fetches and aggregates data efficiently from the database in parallel. /// - /// The list of project IDs to retrieve. - /// A list of ProjectInfoVMs. - private async Task> GetProjectInfosByIdsAsync(List projectIds) - { - // --- Step 1: Fetch from Cache --- - // The cache returns a list of MongoDB documents for the projects it found. - var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); - var finalViewModels = _mapper.Map>(cachedMongoDocs); - - _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); - - // --- Step 2: Identify Missing Projects --- - // If we found everything in the cache, we can return early. - if (finalViewModels.Count == projectIds.Count) - { - return finalViewModels; - } - - var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id - var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); - - // --- Step 3: Fetch Missing from Database --- - if (missingIds.Any()) - { - _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); - - var projectsFromDb = await _context.Projects - .Where(p => missingIds.Contains(p.Id)) - .AsNoTracking() // Use AsNoTracking for read-only query performance - .ToListAsync(); - - if (projectsFromDb.Any()) - { - // Map the newly fetched projects (from SQL) to their ViewModel - var vmsFromDb = _mapper.Map>(projectsFromDb); - finalViewModels.AddRange(vmsFromDb); - - // --- Step 4: Update Cache with Missing Items in a new scope --- - _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); - await _cache.AddProjectDetailsList(projectsFromDb); - } - } - - return finalViewModels; - } + /// An ApiResponse containing a list of projects or an error. [HttpGet("list")] - public async Task GetAll() + public async Task GetAllProjects() { + // --- Step 1: Input Validation and Initial Setup --- if (!ModelState.IsValid) { var errors = ModelState.Values .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + _logger.LogWarning("GetAllProjects called with invalid model state. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - Guid tenantId = _userHelper.GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - //List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(LoggedInEmployee.Id); - //string[] projectsId = []; - //List projects = new List(); - ///* User with permission manage project can see all projects */ - //if (featurePermission != null && featurePermission.Exists(c => c.Id.ToString() == "172fc9b6-755b-4f62-ab26-55c34a330614")) - //{ - // projects = await _projectsHelper.GetAllProjectByTanentID(LoggedInEmployee.TenantId); - //} - //else - //{ - // List allocation = await _projectsHelper.GetProjectByEmployeeID(LoggedInEmployee.Id); - // projectsId = allocation.Select(c => c.ProjectId.ToString()).ToArray(); - // projects = await _context.Projects.Where(c => projectsId.Contains(c.Id.ToString()) && c.TenantId == tenantId).ToListAsync(); - //} - - //List projects = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - ////List projects = new List(); - /// - List response = new List(); - List projectIds = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - - var projectsDetails = await _cache.GetProjectDetailsList(projectIds); - if (projectsDetails == null) + try { - List projects = await _context.Projects.Where(p => projectIds.Contains(p.Id)).ToListAsync(); + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + _logger.LogInfo("Starting GetAllProjects for TenantId: {TenantId}, User: {UserId}", tenantId, loggedInEmployee.Id); - var teams = await _context.ProjectAllocations.Where(p => p.TenantId == tenantId && projectIds.Contains(p.ProjectId) && p.IsActive == true).ToListAsync(); - - - List allBuildings = await _context.Buildings.Where(b => projectIds.Contains(b.ProjectId) && b.TenantId == tenantId).ToListAsync(); - List idList = allBuildings.Select(b => b.Id).ToList(); - - List allFloors = await _context.Floor.Where(f => idList.Contains(f.BuildingId) && f.TenantId == tenantId).ToListAsync(); - idList = allFloors.Select(f => f.Id).ToList(); - - List allWorkAreas = await _context.WorkAreas.Where(a => idList.Contains(a.FloorId) && a.TenantId == tenantId).ToListAsync(); - idList = allWorkAreas.Select(a => a.Id).ToList(); - - List allWorkItems = await _context.WorkItems.Where(i => idList.Contains(i.WorkAreaId) && i.TenantId == tenantId).Include(i => i.ActivityMaster).ToListAsync(); - - foreach (var project in projects) + // --- Step 2: Get a list of project IDs the user can access --- + List projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + if (!projectIds.Any()) { - var result = _mapper.Map(project); - var team = teams.Where(p => p.TenantId == tenantId && p.ProjectId == project.Id && p.IsActive == true).ToList(); - - result.TeamSize = team.Count(); - - List buildings = allBuildings.Where(b => b.ProjectId == project.Id && b.TenantId == tenantId).ToList(); - idList = buildings.Select(b => b.Id).ToList(); - - List floors = allFloors.Where(f => idList.Contains(f.BuildingId) && f.TenantId == tenantId).ToList(); - idList = floors.Select(f => f.Id).ToList(); - - List workAreas = allWorkAreas.Where(a => idList.Contains(a.FloorId) && a.TenantId == tenantId).ToList(); - idList = workAreas.Select(a => a.Id).ToList(); - - List workItems = allWorkItems.Where(i => idList.Contains(i.WorkAreaId) && i.TenantId == tenantId).ToList(); - double completedTask = 0; - double plannedTask = 0; - foreach (var workItem in workItems) - { - completedTask += workItem.CompletedWork; - plannedTask += workItem.PlannedWork; - } - result.PlannedWork = plannedTask; - result.CompletedWork = completedTask; - response.Add(result); + _logger.LogInfo("User has no assigned projects. Returning empty list."); + return Ok(ApiResponse>.SuccessResponse(new List(), "No projects found for the current user.", 200)); } - } - else - { - response = projectsDetails.Select(p => _mapper.Map(p)).ToList(); - } - return Ok(ApiResponse.SuccessResponse(response, "Success.", 200)); + // --- Step 3: Efficiently handle partial cache hits --- + _logger.LogInfo("Attempting to fetch details for {ProjectCount} projects from cache.", projectIds.Count); + + // Fetch what we can from the cache. + var cachedDetails = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var cachedDictionary = cachedDetails.ToDictionary(p => Guid.Parse(p.Id)); + + // Identify which projects are missing from the cache. + var missingIds = projectIds.Where(id => !cachedDictionary.ContainsKey(id)).ToList(); + + // Start building the response with the items we found in the cache. + var responseVms = _mapper.Map>(cachedDictionary.Values); + + if (missingIds.Any()) + { + // --- Step 4: Fetch ONLY the missing items from the database --- + _logger.LogInfo("Cache partial MISS. Found {CachedCount}, fetching {MissingCount} projects from DB.", + cachedDictionary.Count, missingIds.Count); + + // Call our dedicated data-fetching method for the missing IDs. + var newMongoDetails = await FetchAndBuildProjectDetails(missingIds, tenantId); + + if (newMongoDetails.Any()) + { + // Map the newly fetched items and add them to our response list. + responseVms.AddRange(newMongoDetails); + } + } + else + { + _logger.LogInfo("Cache HIT. All {ProjectCount} projects found in cache.", projectIds.Count); + } + + // --- Step 5: Return the combined result --- + _logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", responseVms.Count); + return Ok(ApiResponse>.SuccessResponse(responseVms, "Projects retrieved successfully.", 200)); + } + catch (Exception ex) + { + // --- Step 6: Graceful Error Handling --- + _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. : {Error}", tenantId, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500)); + } } [HttpGet("get/{id}")] @@ -351,23 +242,6 @@ namespace MarcoBMS.Services.Controllers { projectVM.ProjectStatus.TenantId = tenantId; } - //projectVM = new ProjectVM - //{ - // Id = projectDetails.Id != null ? Guid.Parse(projectDetails.Id) : Guid.Empty, - // Name = projectDetails.Name, - // ShortName = projectDetails.ShortName, - // ProjectAddress = projectDetails.ProjectAddress, - // StartDate = projectDetails.StartDate, - // EndDate = projectDetails.EndDate, - // ContactPerson = projectDetails.ContactPerson, - // ProjectStatus = new StatusMaster - // { - // Id = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, - // Status = projectDetails.ProjectStatus?.Status, - // TenantId = tenantId - // } - // //ProjectStatusId = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, - //}; } if (projectVM == null) @@ -486,40 +360,9 @@ namespace MarcoBMS.Services.Controllers } - private async Task GetProjectViewModel(Guid? id, Project project) - { - ProjectDetailsVM vm = new ProjectDetailsVM(); + #endregion - // List buildings = _unitOfWork.Building.GetAll(c => c.ProjectId == id).ToList(); - List buildings = await _context.Buildings.Where(c => c.ProjectId == id).ToListAsync(); - List idList = buildings.Select(o => o.Id).ToList(); - // List floors = _unitOfWork.Floor.GetAll(c => idList.Contains(c.Id)).ToList(); - List floors = await _context.Floor.Where(c => idList.Contains(c.BuildingId)).ToListAsync(); - idList = floors.Select(o => o.Id).ToList(); - //List workAreas = _unitOfWork.WorkArea.GetAll(c => idList.Contains(c.Id), includeProperties: "WorkItems,WorkItems.ActivityMaster").ToList(); - - List workAreas = await _context.WorkAreas.Where(c => idList.Contains(c.FloorId)).ToListAsync(); - - idList = workAreas.Select(o => o.Id).ToList(); - List workItems = await _context.WorkItems.Include(c => c.WorkCategoryMaster).Where(c => idList.Contains(c.WorkAreaId)).Include(c => c.ActivityMaster).ToListAsync(); - // List workItems = _unitOfWork.WorkItem.GetAll(c => idList.Contains(c.WorkAreaId), includeProperties: "ActivityMaster").ToList(); - idList = workItems.Select(t => t.Id).ToList(); - List tasks = await _context.TaskAllocations.Where(t => idList.Contains(t.WorkItemId) && t.AssignmentDate.Date == DateTime.UtcNow.Date).ToListAsync(); - vm.project = project; - vm.buildings = buildings; - vm.floors = floors; - vm.workAreas = workAreas; - vm.workItems = workItems; - vm.Tasks = tasks; - return vm; - } - - private Guid GetTenantId() - { - return _userHelper.GetTenantId(); - //var tenant = User.FindFirst("TenantId")?.Value; - //return (tenant != null ? Convert.ToInt32(tenant) : 1); - } + #region =================================================================== Project Manage APIs =================================================================== [HttpPost] public async Task Create([FromBody] CreateProjectDto projectDto) @@ -619,50 +462,9 @@ namespace MarcoBMS.Services.Controllers } } - //[HttpPost("assign-employee")] - //public async Task AssignEmployee(int? allocationid, int employeeId, int projectId) - //{ - // var employee = await _context.Employees.FindAsync(employeeId); - // var project = _projectrepo.Get(c => c.Id == projectId); - // if (employee == null || project == null) - // { - // return NotFound(); - // } + #endregion - // // Logic to add the product to a new table (e.g., selected products) - - // if (allocationid == null) - // { - // // Add allocation - // ProjectAllocation allocation = new ProjectAllocation() - // { - // EmployeeId = employeeId, - // ProjectId = project.Id, - // AllocationDate = DateTime.UtcNow, - // //EmployeeRole = employee.Rol - // TenantId = project.TenantId - // }; - - // _unitOfWork.ProjectAllocation.CreateAsync(allocation); - // } - // else - // { - // //remove allocation - // var allocation = await _context.ProjectAllocations.FindAsync(allocationid); - // if (allocation != null) - // { - // allocation.ReAllocationDate = DateTime.UtcNow; - - // _unitOfWork.ProjectAllocation.UpdateAsync(allocation.Id, allocation); - // } - // else - // { - // return NotFound(); - // } - // } - - // return Ok(); - //} + #region =================================================================== Project Allocation APIs =================================================================== [HttpGet] [Route("employees/get/{projectid?}/{includeInactive?}")] @@ -838,6 +640,134 @@ namespace MarcoBMS.Services.Controllers } + [HttpGet("assigned-projects/{employeeId}")] + public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) + { + + Guid tenantId = _userHelper.GetTenantId(); + if (employeeId == Guid.Empty) + { + return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Employee id not valid.", 400)); + } + + List projectList = await _context.ProjectAllocations + .Where(c => c.TenantId == tenantId && c.EmployeeId == employeeId && c.IsActive) + .Select(c => c.ProjectId).Distinct() + .ToListAsync(); + + if (!projectList.Any()) + { + return NotFound(ApiResponse.SuccessResponse(new List(), "No projects found.", 200)); + } + + + List projectlist = await _context.Projects + .Where(p => projectList.Contains(p.Id)) + .ToListAsync(); + + List projects = new List(); + + + foreach (var project in projectlist) + { + + projects.Add(project.ToProjectListVMFromProject()); + } + + + + return Ok(ApiResponse.SuccessResponse(projects, "Success.", 200)); + } + + [HttpPost("assign-projects/{employeeId}")] + public async Task AssigneProjectsToEmployee([FromBody] List projectAllocationDtos, [FromRoute] Guid employeeId) + { + if (projectAllocationDtos != null && employeeId != Guid.Empty) + { + Guid TenentID = GetTenantId(); + var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + List? result = new List(); + List projectIds = new List(); + + foreach (var projectAllocationDto in projectAllocationDtos) + { + try + { + ProjectAllocation projectAllocation = projectAllocationDto.ToProjectAllocationFromProjectsAllocationDto(TenentID, employeeId); + ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.ProjectId == projectAllocationDto.ProjectId && c.ReAllocationDate == null && c.TenantId == TenentID).SingleOrDefaultAsync(); + + if (projectAllocationFromDb != null) + { + + + _context.ProjectAllocations.Attach(projectAllocationFromDb); + + if (projectAllocationDto.Status) + { + projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; + projectAllocationFromDb.IsActive = true; + _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; + _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; + } + else + { + projectAllocationFromDb.ReAllocationDate = DateTime.UtcNow; + projectAllocationFromDb.IsActive = false; + _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; + _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; + + projectIds.Add(projectAllocation.ProjectId); + } + await _context.SaveChangesAsync(); + var result1 = new + { + Id = projectAllocationFromDb.Id, + EmployeeId = projectAllocation.EmployeeId, + JobRoleId = projectAllocation.JobRoleId, + IsActive = projectAllocation.IsActive, + ProjectId = projectAllocation.ProjectId, + AllocationDate = projectAllocation.AllocationDate, + ReAllocationDate = projectAllocation.ReAllocationDate, + TenantId = projectAllocation.TenantId + }; + result.Add(result1); + } + else + { + projectAllocation.AllocationDate = DateTime.Now; + projectAllocation.IsActive = true; + _context.ProjectAllocations.Add(projectAllocation); + await _context.SaveChangesAsync(); + + projectIds.Add(projectAllocation.ProjectId); + + } + + + } + catch (Exception ex) + { + + return Ok(ApiResponse.ErrorResponse(ex.Message, ex, 400)); + } + } + await _cache.ClearAllProjectIds(employeeId); + var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; + + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + + return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); + } + else + { + return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "All Field is required", 400)); + } + + } + + #endregion + + #region =================================================================== Project InfraStructure Get APIs =================================================================== [HttpGet("infra-details/{projectId}")] public async Task GetInfraDetails(Guid projectId) @@ -1026,6 +956,10 @@ namespace MarcoBMS.Services.Controllers return Ok(ApiResponse.SuccessResponse(workItemVMs, $"{workItemVMs.Count} records of tasks fetched successfully", 200)); } + #endregion + + #region =================================================================== Project Infrastructre Manage APIs =================================================================== + [HttpPost("task")] public async Task CreateProjectTask(List workItemDtos) { @@ -1309,131 +1243,172 @@ namespace MarcoBMS.Services.Controllers } - [HttpGet("assigned-projects/{employeeId}")] - public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) + #endregion + + #region =================================================================== Helper Functions =================================================================== + + /// + /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. + /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the + /// database (as Project), updates the cache, and returns a unified list of ViewModels. + /// + /// The list of project IDs to retrieve. + /// A list of ProjectInfoVMs. + private async Task> GetProjectInfosByIdsAsync(List projectIds) { + // --- Step 1: Fetch from Cache --- + // The cache returns a list of MongoDB documents for the projects it found. + var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var finalViewModels = _mapper.Map>(cachedMongoDocs); - Guid tenantId = _userHelper.GetTenantId(); - if (employeeId == Guid.Empty) + _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); + + // --- Step 2: Identify Missing Projects --- + // If we found everything in the cache, we can return early. + if (finalViewModels.Count == projectIds.Count) { - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Employee id not valid.", 400)); + return finalViewModels; } - List projectList = await _context.ProjectAllocations - .Where(c => c.TenantId == tenantId && c.EmployeeId == employeeId && c.IsActive) - .Select(c => c.ProjectId).Distinct() - .ToListAsync(); + var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id + var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); - if (!projectList.Any()) + // --- Step 3: Fetch Missing from Database --- + if (missingIds.Any()) { - return NotFound(ApiResponse.SuccessResponse(new List(), "No projects found.", 200)); - } + _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); + var projectsFromDb = await _context.Projects + .Where(p => missingIds.Contains(p.Id)) + .AsNoTracking() // Use AsNoTracking for read-only query performance + .ToListAsync(); - List projectlist = await _context.Projects - .Where(p => projectList.Contains(p.Id)) - .ToListAsync(); - - List projects = new List(); - - - foreach (var project in projectlist) - { - - projects.Add(project.ToProjectListVMFromProject()); - } - - - - return Ok(ApiResponse.SuccessResponse(projects, "Success.", 200)); - } - - [HttpPost("assign-projects/{employeeId}")] - public async Task AssigneProjectsToEmployee([FromBody] List projectAllocationDtos, [FromRoute] Guid employeeId) - { - if (projectAllocationDtos != null && employeeId != Guid.Empty) - { - Guid TenentID = GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - List? result = new List(); - List projectIds = new List(); - - foreach (var projectAllocationDto in projectAllocationDtos) + if (projectsFromDb.Any()) { - try - { - ProjectAllocation projectAllocation = projectAllocationDto.ToProjectAllocationFromProjectsAllocationDto(TenentID, employeeId); - ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.ProjectId == projectAllocationDto.ProjectId && c.ReAllocationDate == null && c.TenantId == TenentID).SingleOrDefaultAsync(); + // Map the newly fetched projects (from SQL) to their ViewModel + var vmsFromDb = _mapper.Map>(projectsFromDb); + finalViewModels.AddRange(vmsFromDb); - if (projectAllocationFromDb != null) - { - - - _context.ProjectAllocations.Attach(projectAllocationFromDb); - - if (projectAllocationDto.Status) - { - projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; - projectAllocationFromDb.IsActive = true; - _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - } - else - { - projectAllocationFromDb.ReAllocationDate = DateTime.UtcNow; - projectAllocationFromDb.IsActive = false; - _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - - projectIds.Add(projectAllocation.ProjectId); - } - await _context.SaveChangesAsync(); - var result1 = new - { - Id = projectAllocationFromDb.Id, - EmployeeId = projectAllocation.EmployeeId, - JobRoleId = projectAllocation.JobRoleId, - IsActive = projectAllocation.IsActive, - ProjectId = projectAllocation.ProjectId, - AllocationDate = projectAllocation.AllocationDate, - ReAllocationDate = projectAllocation.ReAllocationDate, - TenantId = projectAllocation.TenantId - }; - result.Add(result1); - } - else - { - projectAllocation.AllocationDate = DateTime.Now; - projectAllocation.IsActive = true; - _context.ProjectAllocations.Add(projectAllocation); - await _context.SaveChangesAsync(); - - projectIds.Add(projectAllocation.ProjectId); - - } - - - } - catch (Exception ex) - { - - return Ok(ApiResponse.ErrorResponse(ex.Message, ex, 400)); - } + // --- Step 4: Update Cache with Missing Items in a new scope --- + _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); + await _cache.AddProjectDetailsList(projectsFromDb); } - await _cache.ClearAllProjectIds(employeeId); - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; - - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - - return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); - } - else - { - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "All Field is required", 400)); } + return finalViewModels; + } + + private Guid GetTenantId() + { + return _userHelper.GetTenantId(); + } + + private async Task GetProjectViewModel(Guid? id, Project project) + { + ProjectDetailsVM vm = new ProjectDetailsVM(); + + // List buildings = _unitOfWork.Building.GetAll(c => c.ProjectId == id).ToList(); + List buildings = await _context.Buildings.Where(c => c.ProjectId == id).ToListAsync(); + List idList = buildings.Select(o => o.Id).ToList(); + // List floors = _unitOfWork.Floor.GetAll(c => idList.Contains(c.Id)).ToList(); + List floors = await _context.Floor.Where(c => idList.Contains(c.BuildingId)).ToListAsync(); + idList = floors.Select(o => o.Id).ToList(); + //List workAreas = _unitOfWork.WorkArea.GetAll(c => idList.Contains(c.Id), includeProperties: "WorkItems,WorkItems.ActivityMaster").ToList(); + + List workAreas = await _context.WorkAreas.Where(c => idList.Contains(c.FloorId)).ToListAsync(); + + idList = workAreas.Select(o => o.Id).ToList(); + List workItems = await _context.WorkItems.Include(c => c.WorkCategoryMaster).Where(c => idList.Contains(c.WorkAreaId)).Include(c => c.ActivityMaster).ToListAsync(); + // List workItems = _unitOfWork.WorkItem.GetAll(c => idList.Contains(c.WorkAreaId), includeProperties: "ActivityMaster").ToList(); + idList = workItems.Select(t => t.Id).ToList(); + List tasks = await _context.TaskAllocations.Where(t => idList.Contains(t.WorkItemId) && t.AssignmentDate.Date == DateTime.UtcNow.Date).ToListAsync(); + vm.project = project; + vm.buildings = buildings; + vm.floors = floors; + vm.workAreas = workAreas; + vm.workItems = workItems; + vm.Tasks = tasks; + return vm; } + /// + /// Fetches project details from the database for a given list of project IDs and assembles them into MongoDB models. + /// This method encapsulates the optimized, parallel database queries. + /// + /// The list of project IDs to fetch. + /// The current tenant ID for filtering. + /// A list of fully populated ProjectMongoDB objects. + private async Task> FetchAndBuildProjectDetails(List projectIdsToFetch, Guid tenantId) + { + // Task to get base project details for the MISSING projects + var projectsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Projects.AsNoTracking() + .Where(p => projectIdsToFetch.Contains(p.Id) && p.TenantId == tenantId) + .ToListAsync(); + }); + + // Task to get team sizes for the MISSING projects + var teamSizesTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.ProjectAllocations.AsNoTracking() + .Where(pa => pa.TenantId == tenantId && projectIdsToFetch.Contains(pa.ProjectId) && pa.IsActive) + .GroupBy(pa => pa.ProjectId) + .Select(g => new { ProjectId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ProjectId, x => x.Count); + }); + + // Task to get work summaries for the MISSING projects + var workSummariesTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.WorkItems.AsNoTracking() + .Where(wi => wi.TenantId == tenantId && + wi.WorkArea != null && + wi.WorkArea.Floor != null && + wi.WorkArea.Floor.Building != null && + projectIdsToFetch.Contains(wi.WorkArea.Floor.Building.ProjectId)) + .GroupBy(wi => wi.WorkArea!.Floor!.Building!.ProjectId) + .Select(g => new { ProjectId = g.Key, PlannedWork = g.Sum(i => i.PlannedWork), CompletedWork = g.Sum(i => i.CompletedWork) }) + .ToDictionaryAsync(x => x.ProjectId); + }); + + // Await all parallel tasks to complete + await Task.WhenAll(projectsTask, teamSizesTask, workSummariesTask); + + var projects = await projectsTask; + var teamSizes = await teamSizesTask; + var workSummaries = await workSummariesTask; + + // Proactively update the cache with the items we just fetched. + _logger.LogInfo("Updating cache with {NewItemCount} newly fetched projects.", projects.Count); + await _cache.AddProjectDetailsList(projects); + + // This section would build the full ProjectMongoDB objects, similar to your AddProjectDetailsList method. + // For brevity, assuming you have a mapper or a builder for this. Here's a simplified representation: + var mongoDetailsList = new List(); + foreach (var project in projects) + { + // This is a placeholder for the full build logic from your other methods. + // In a real scenario, you would fetch all hierarchy levels (buildings, floors, etc.) + // for the `projectIdsToFetch` and build the complete MongoDB object. + var mongoDetail = _mapper.Map(project); + mongoDetail.Id = project.Id; + mongoDetail.TeamSize = teamSizes.GetValueOrDefault(project.Id, 0); + if (workSummaries.TryGetValue(project.Id, out var summary)) + { + mongoDetail.PlannedWork = summary.PlannedWork; + mongoDetail.CompletedWork = summary.CompletedWork; + } + mongoDetailsList.Add(mongoDetail); + } + + return mongoDetailsList; + } + + #endregion } } \ No newline at end of file diff --git a/Marco.Pms.Services/Controllers/UserController.cs b/Marco.Pms.Services/Controllers/UserController.cs index 2aeb208..4bb4432 100644 --- a/Marco.Pms.Services/Controllers/UserController.cs +++ b/Marco.Pms.Services/Controllers/UserController.cs @@ -50,7 +50,7 @@ namespace MarcoBMS.Services.Controllers emp = await _employeeHelper.GetEmployeeByApplicationUserID(user.Id); } - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(emp.Id); + List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeId(emp.Id); string[] projectsId = []; /* User with permission manage project can see all projects */ diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 589ab52..4369b5b 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -1,5 +1,6 @@ using Marco.Pms.CacheHelper; using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.Master; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using MarcoBMS.Services.Service; @@ -15,20 +16,20 @@ namespace Marco.Pms.Services.Helpers private readonly ReportCache _reportCache; private readonly ILoggingService _logger; private readonly IDbContextFactory _dbContextFactory; + private readonly ApplicationDbContext _context; public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ReportCache reportCache, ILoggingService logger, - IDbContextFactory dbContextFactory) + IDbContextFactory dbContextFactory, ApplicationDbContext context) { _projectCache = projectCache; _employeeCache = employeeCache; _reportCache = reportCache; _logger = logger; _dbContextFactory = dbContextFactory; + _context = context; } // ------------------------------------ Project Details Cache --------------------------------------- - // Assuming you have access to an IDbContextFactory as _dbContextFactory - // This is crucial for safe parallel database operations. public async Task AddProjectDetails(Project project) { @@ -417,9 +418,11 @@ namespace Marco.Pms.Services.Helpers } public async Task UpdateProjectDetailsOnly(Project project) { + StatusMaster projectStatus = await _context.StatusMasters + .FirstOrDefaultAsync(s => s.Id == project.ProjectStatusId) ?? new StatusMaster(); try { - bool response = await _projectCache.UpdateProjectDetailsOnlyToCache(project); + bool response = await _projectCache.UpdateProjectDetailsOnlyToCache(project, projectStatus); return response; } catch (Exception ex) @@ -457,10 +460,22 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogWarning("Error occured while getting list od project details from to Cache: {Error}", ex.Message); + _logger.LogWarning("Error occured while getting list of project details from to Cache: {Error}", ex.Message); return null; } } + public async Task DeleteProjectByIdAsync(Guid projectId) + { + try + { + var response = await _projectCache.DeleteProjectByIdFromCacheAsync(projectId); + } + catch (Exception ex) + { + _logger.LogWarning("Error occured while deleting project from to Cache: {Error}", ex.Message); + + } + } // ------------------------------------ Project Infrastructure Cache --------------------------------------- @@ -527,6 +542,9 @@ namespace Marco.Pms.Services.Helpers return null; } } + + // ------------------------------------------------------- WorkItem ------------------------------------------------------- + public async Task?> GetWorkItemsByWorkAreaIds(List workAreaIds) { try @@ -544,9 +562,6 @@ namespace Marco.Pms.Services.Helpers return null; } } - - // ------------------------------------------------------- WorkItem ------------------------------------------------------- - public async Task ManageWorkItemDetails(List workItems) { try @@ -609,6 +624,18 @@ namespace Marco.Pms.Services.Helpers _logger.LogWarning("Error occured while updating planned work, completed work, and today's assigned work in workItems in Cache: {Error}", ex.Message); } } + public async Task DeleteWorkItemByIdAsync(Guid workItemId) + { + try + { + var response = await _projectCache.DeleteWorkItemByIdFromCacheAsync(workItemId); + } + catch (Exception ex) + { + _logger.LogWarning("Error occured while deleting work item from to Cache: {Error}", ex.Message); + + } + } // ------------------------------------ Employee Profile Cache --------------------------------------- diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs index fb5b6f2..6c1cab1 100644 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ b/Marco.Pms.Services/Helpers/ProjectsHelper.cs @@ -58,7 +58,7 @@ namespace MarcoBMS.Services.Helpers if (projectIds == null) { - var hasPermission = await _permission.HasPermission(LoggedInEmployee.Id, PermissionsMaster.ManageProject); + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, LoggedInEmployee.Id); if (hasPermission) { var projects = await _context.Projects.Where(c => c.TenantId == tenantId).ToListAsync(); diff --git a/Marco.Pms.Services/Helpers/RolesHelper.cs b/Marco.Pms.Services/Helpers/RolesHelper.cs index 15bf0b1..1688dce 100644 --- a/Marco.Pms.Services/Helpers/RolesHelper.cs +++ b/Marco.Pms.Services/Helpers/RolesHelper.cs @@ -3,6 +3,7 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Entitlements; using Marco.Pms.Services.Helpers; +using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; namespace MarcoBMS.Services.Helpers @@ -11,33 +12,81 @@ namespace MarcoBMS.Services.Helpers { private readonly ApplicationDbContext _context; private readonly CacheUpdateHelper _cache; - public RolesHelper(ApplicationDbContext context, CacheUpdateHelper cache) + private readonly ILoggingService _logger; + public RolesHelper(ApplicationDbContext context, CacheUpdateHelper cache, ILoggingService logger) { _context = context; _cache = cache; + _logger = logger; } - public async Task> GetFeaturePermissionByEmployeeID(Guid EmployeeID) + /// + /// Retrieves a unique list of enabled feature permissions for a given employee. + /// This method is optimized to use a single, composed database query. + /// + /// The ID of the employee. + /// A distinct list of FeaturePermission objects the employee is granted. + public async Task> GetFeaturePermissionByEmployeeId(Guid EmployeeId) { - List roleMappings = await _context.EmployeeRoleMappings.Where(c => c.EmployeeId == EmployeeID && c.IsEnabled == true).Select(c => c.RoleId).ToListAsync(); + _logger.LogInfo("Fetching feature permissions for EmployeeId: {EmployeeId}", EmployeeId); - await _cache.AddApplicationRole(EmployeeID, roleMappings); + try + { + // --- Step 1: Define the subquery for the employee's roles --- + // This is an IQueryable, not a list. It will be composed directly into the main query + // by Entity Framework, avoiding a separate database call. + var employeeRoleIdsQuery = _context.EmployeeRoleMappings + .Where(erm => erm.EmployeeId == EmployeeId && erm.IsEnabled == true) + .Select(erm => erm.RoleId); - // _context.RolePermissionMappings + // --- Step 2: Asynchronously update the cache in the background (Fire and Forget) --- + // This task is started but not awaited. The main function continues immediately, + // reducing latency. The cache will be updated eventually without blocking the user. + _ = Task.Run(async () => + { + try + { + var roleIds = await employeeRoleIdsQuery.ToListAsync(); // Execute the query for the cache + if (roleIds.Any()) + { + await _cache.AddApplicationRole(EmployeeId, roleIds); + _logger.LogInfo("Successfully queued cache update for EmployeeId: {EmployeeId}", EmployeeId); + } + } + catch (Exception ex) + { + // Log errors from the background task so they are not lost. + _logger.LogWarning("Background cache update failed for EmployeeId {EmployeeId} : {Error}", EmployeeId, ex.Message); + } + }); - var result = await (from rpm in _context.RolePermissionMappings - join fp in _context.FeaturePermissions.Where(c => c.IsEnabled == true).Include(fp => fp.Feature) // Include Feature - on rpm.FeaturePermissionId equals fp.Id - where roleMappings.Contains(rpm.ApplicationRoleId) - select fp) - .ToListAsync(); + // --- Step 3: Execute the main query to get permissions in a single database call --- + // This single, efficient query gets all the required data at once. + var permissions = await ( + from rpm in _context.RolePermissionMappings + join fp in _context.FeaturePermissions.Include(f => f.Feature) // Include related Feature data + on rpm.FeaturePermissionId equals fp.Id + // The 'employeeRoleIdsQuery' subquery is seamlessly integrated here by EF Core, + // resulting in a SQL "IN (SELECT ...)" clause. + where employeeRoleIdsQuery.Contains(rpm.ApplicationRoleId) && fp.IsEnabled == true + select fp) + .Distinct() // Ensures each permission is returned only once + .ToListAsync(); - return result; + _logger.LogInfo("Successfully retrieved {PermissionCount} unique permissions for EmployeeId: {EmployeeId}", permissions.Count, EmployeeId); - // return null; + return permissions; + } + catch (Exception ex) + { + _logger.LogError("An error occurred while fetching permissions for EmployeeId {EmployeeId} :{Error}", EmployeeId, ex.Message); + // Depending on your application's error handling strategy, you might re-throw, + // or return an empty list to prevent downstream failures. + return new List(); + } } - public async Task> GetFeaturePermissionByRoleID(Guid roleId) + public async Task> GetFeaturePermissionByRoleID1(Guid roleId) { List roleMappings = await _context.RolePermissionMappings.Where(c => c.ApplicationRoleId == roleId).Select(c => c.ApplicationRoleId).ToListAsync(); @@ -54,5 +103,49 @@ namespace MarcoBMS.Services.Helpers // return null; } + /// + /// Retrieves a unique list of enabled feature permissions for a given role. + /// This method is optimized to fetch all data in a single, efficient database query. + /// + /// The ID of the role. + /// A distinct list of FeaturePermission objects granted to the role. + public async Task> GetFeaturePermissionByRoleID(Guid roleId) + { + _logger.LogInfo("Fetching feature permissions for RoleID: {RoleId}", roleId); + + try + { + // This single, efficient query gets all the required data at once. + // It joins the mapping table to the permissions table and filters by the given roleId. + var permissions = await ( + // 1. Start with the linking table. + from rpm in _context.RolePermissionMappings + + // 2. Join to the FeaturePermissions table on the foreign key. + join fp in _context.FeaturePermissions on rpm.FeaturePermissionId equals fp.Id + + // 3. Apply all filters in one 'where' clause for clarity and efficiency. + where + rpm.ApplicationRoleId == roleId // Filter by the specific role + && fp.IsEnabled == true // And only get enabled permissions + + // 4. Select the final FeaturePermission object. + select fp) + .Include(fp => fp.Feature) + .Distinct() + .ToListAsync(); + + _logger.LogInfo("Successfully retrieved {PermissionCount} unique permissions for RoleID: {RoleId}", permissions.Count, roleId); + + return permissions; + } + catch (Exception ex) + { + _logger.LogError("An error occurred while fetching permissions for RoleId {RoleId}: {Error}", roleId, ex.Message); + // Return an empty list as a safe default to prevent downstream failures. + return new List(); + } + } + } } diff --git a/Marco.Pms.Services/Service/PermissionServices.cs b/Marco.Pms.Services/Service/PermissionServices.cs index 7162dc5..f20a768 100644 --- a/Marco.Pms.Services/Service/PermissionServices.cs +++ b/Marco.Pms.Services/Service/PermissionServices.cs @@ -24,7 +24,7 @@ namespace Marco.Pms.Services.Service var featurePermissionIds = await _cache.GetPermissions(employeeId); if (featurePermissionIds == null) { - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(employeeId); + List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeId(employeeId); featurePermissionIds = featurePermission.Select(fp => fp.Id).ToList(); } var hasPermission = featurePermissionIds.Contains(featurePermissionId); -- 2.43.0 From b78f58c304b27aa33eafa5b1fb939e14e8b03e4f Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 14 Jul 2025 15:08:31 +0530 Subject: [PATCH 28/50] Solved Concurrency Issue --- Marco.Pms.CacheHelper/EmployeeCache.cs | 19 +------- .../Helpers/CacheUpdateHelper.cs | 23 +++++++++- Marco.Pms.Services/Helpers/RolesHelper.cs | 43 ++++++++++--------- 3 files changed, 46 insertions(+), 39 deletions(-) diff --git a/Marco.Pms.CacheHelper/EmployeeCache.cs b/Marco.Pms.CacheHelper/EmployeeCache.cs index c2a1f7b..4a668f0 100644 --- a/Marco.Pms.CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.CacheHelper/EmployeeCache.cs @@ -20,29 +20,12 @@ namespace Marco.Pms.CacheHelper var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name _collection = mongoDB.GetCollection("EmployeeProfile"); } - public async Task AddApplicationRoleToCache(Guid employeeId, List roleIds) + public async Task AddApplicationRoleToCache(Guid employeeId, List newRoleIds, List newPermissionIds) { - // 1. Guard Clause: Avoid unnecessary database work if there are no roles to add. - if (roleIds == null || !roleIds.Any()) - { - return false; // Nothing to add, so the operation did not result in a change. - } // 2. Perform database queries concurrently for better performance. var employeeIdString = employeeId.ToString(); - Task> getPermissionIdsTask = _context.RolePermissionMappings - .Where(rp => roleIds.Contains(rp.ApplicationRoleId)) - .Select(p => p.FeaturePermissionId.ToString()) - .Distinct() - .ToListAsync(); - - // 3. Prepare role IDs in parallel with the database query. - var newRoleIds = roleIds.Select(r => r.ToString()).ToList(); - - // 4. Await the database query result. - var newPermissionIds = await getPermissionIdsTask; - // 5. Build a single, efficient update operation. var filter = Builders.Filter.Eq(e => e.Id, employeeIdString); diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 4369b5b..5bae90f 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -641,9 +641,30 @@ namespace Marco.Pms.Services.Helpers // ------------------------------------ Employee Profile Cache --------------------------------------- public async Task AddApplicationRole(Guid employeeId, List roleIds) { + // 1. Guard Clause: Avoid unnecessary database work if there are no roles to add. + if (roleIds == null || !roleIds.Any()) + { + return; // Nothing to add, so the operation did not result in a change. + } + Task> getPermissionIdsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + + return await context.RolePermissionMappings + .Where(rp => roleIds.Contains(rp.ApplicationRoleId)) + .Select(p => p.FeaturePermissionId.ToString()) + .Distinct() + .ToListAsync(); + }); + + // 3. Prepare role IDs in parallel with the database query. + var newRoleIds = roleIds.Select(r => r.ToString()).ToList(); + + // 4. Await the database query result. + var newPermissionIds = await getPermissionIdsTask; try { - var response = await _employeeCache.AddApplicationRoleToCache(employeeId, roleIds); + var response = await _employeeCache.AddApplicationRoleToCache(employeeId, newRoleIds, newPermissionIds); } catch (Exception ex) { diff --git a/Marco.Pms.Services/Helpers/RolesHelper.cs b/Marco.Pms.Services/Helpers/RolesHelper.cs index 1688dce..cd73c0f 100644 --- a/Marco.Pms.Services/Helpers/RolesHelper.cs +++ b/Marco.Pms.Services/Helpers/RolesHelper.cs @@ -10,14 +10,16 @@ namespace MarcoBMS.Services.Helpers { public class RolesHelper { + private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly CacheUpdateHelper _cache; private readonly ILoggingService _logger; - public RolesHelper(ApplicationDbContext context, CacheUpdateHelper cache, ILoggingService logger) + public RolesHelper(ApplicationDbContext context, CacheUpdateHelper cache, ILoggingService logger, IDbContextFactory dbContextFactory) { _context = context; _cache = cache; _logger = logger; + _dbContextFactory = dbContextFactory; } /// @@ -32,56 +34,57 @@ namespace MarcoBMS.Services.Helpers try { - // --- Step 1: Define the subquery for the employee's roles --- - // This is an IQueryable, not a list. It will be composed directly into the main query - // by Entity Framework, avoiding a separate database call. + // --- Step 1: Define the subquery using the main thread's context --- + // This is safe because the query is not executed yet. var employeeRoleIdsQuery = _context.EmployeeRoleMappings - .Where(erm => erm.EmployeeId == EmployeeId && erm.IsEnabled == true) + .Where(erm => erm.EmployeeId == EmployeeId && erm.IsEnabled) .Select(erm => erm.RoleId); - // --- Step 2: Asynchronously update the cache in the background (Fire and Forget) --- - // This task is started but not awaited. The main function continues immediately, - // reducing latency. The cache will be updated eventually without blocking the user. + // --- Step 2: Asynchronously update the cache using the DbContextFactory --- _ = Task.Run(async () => { try { - var roleIds = await employeeRoleIdsQuery.ToListAsync(); // Execute the query for the cache + // Create a NEW, short-lived DbContext instance for this background task. + await using var contextForCache = await _dbContextFactory.CreateDbContextAsync(); + + // Now, re-create and execute the query using this new, isolated context. + var roleIds = await contextForCache.EmployeeRoleMappings + .Where(erm => erm.EmployeeId == EmployeeId && erm.IsEnabled) + .Select(erm => erm.RoleId) + .ToListAsync(); + if (roleIds.Any()) { + // The cache service might also need its own context, or you can pass the data directly. + // Assuming AddApplicationRole takes the data, not a context. await _cache.AddApplicationRole(EmployeeId, roleIds); _logger.LogInfo("Successfully queued cache update for EmployeeId: {EmployeeId}", EmployeeId); } } catch (Exception ex) { - // Log errors from the background task so they are not lost. _logger.LogWarning("Background cache update failed for EmployeeId {EmployeeId} : {Error}", EmployeeId, ex.Message); } }); - // --- Step 3: Execute the main query to get permissions in a single database call --- - // This single, efficient query gets all the required data at once. + // --- Step 3: Execute the main query on the main thread using its original context --- + // This is now safe because the background task is using a different DbContext instance. var permissions = await ( from rpm in _context.RolePermissionMappings - join fp in _context.FeaturePermissions.Include(f => f.Feature) // Include related Feature data + join fp in _context.FeaturePermissions.Include(f => f.Feature) on rpm.FeaturePermissionId equals fp.Id - // The 'employeeRoleIdsQuery' subquery is seamlessly integrated here by EF Core, - // resulting in a SQL "IN (SELECT ...)" clause. where employeeRoleIdsQuery.Contains(rpm.ApplicationRoleId) && fp.IsEnabled == true select fp) - .Distinct() // Ensures each permission is returned only once + .Distinct() .ToListAsync(); _logger.LogInfo("Successfully retrieved {PermissionCount} unique permissions for EmployeeId: {EmployeeId}", permissions.Count, EmployeeId); - return permissions; } catch (Exception ex) { - _logger.LogError("An error occurred while fetching permissions for EmployeeId {EmployeeId} :{Error}", EmployeeId, ex.Message); - // Depending on your application's error handling strategy, you might re-throw, - // or return an empty list to prevent downstream failures. + _logger.LogError("An error occurred while fetching permissions for EmployeeId {EmployeeId} : {Error}", EmployeeId, ex.Message); return new List(); } } -- 2.43.0 From ca34b01ab0e3dc1a0be114c29c2fdc96288a184c Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 14 Jul 2025 15:57:52 +0530 Subject: [PATCH 29/50] Optimized the Get project By ID API --- .../MongoDBModels/StatusMasterMongoDB.cs | 2 +- .../Controllers/ProjectController.cs | 117 +++++++++++++++--- .../MappingProfiles/ProjectMappingProfile.cs | 13 +- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs b/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs index 01a0552..77e8eb5 100644 --- a/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs +++ b/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs @@ -2,7 +2,7 @@ { public class StatusMasterMongoDB { - public string? Id { get; set; } + public string Id { get; set; } = string.Empty; public string? Status { get; set; } } } diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index adb5887..acc97d2 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -40,8 +40,8 @@ namespace MarcoBMS.Services.Controllers private readonly Guid tenantId; - public ProjectController(IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, RolesHelper rolesHelper, - ProjectsHelper projectHelper, IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IMapper mapper) + public ProjectController(IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, + ProjectsHelper projectHelper, IHubContext signalR, CacheUpdateHelper cache, PermissionServices permission, IMapper mapper) { _dbContextFactory = dbContextFactory; _context = context; @@ -52,7 +52,7 @@ namespace MarcoBMS.Services.Controllers _cache = cache; _permission = permission; _mapper = mapper; - tenantId = _userHelper.GetTenantId(); + tenantId = userHelper.GetTenantId(); } #region =================================================================== Project Get APIs =================================================================== @@ -161,29 +161,74 @@ namespace MarcoBMS.Services.Controllers catch (Exception ex) { // --- Step 6: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. : {Error}", tenantId, ex.Message); + _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. \n {Error}", tenantId, ex.Message); return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500)); } } + /// + /// Retrieves details for a specific project by its ID. + /// This endpoint is optimized with a cache-first strategy and parallel permission checks. + /// + /// The unique identifier of the project. + /// An ApiResponse containing the project details or an appropriate error. + [HttpGet("get/{id}")] public async Task Get([FromRoute] Guid id) { + // --- Step 1: Input Validation --- if (!ModelState.IsValid) { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get project called with invalid model state for ID {ProjectId}. Errors: {Errors}", id, string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - var project = await _context.Projects.Where(c => c.TenantId == _userHelper.GetTenantId() && c.Id == id).SingleOrDefaultAsync(); - if (project == null) return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); - return Ok(ApiResponse.SuccessResponse(project, "Success.", 200)); + try + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // --- Step 2: Run independent operations in PARALLEL --- + // We can check permissions and fetch data at the same time to reduce latency. + var permissionTask = _permission.HasProjectPermission(loggedInEmployee, id); + + // This helper method encapsulates the "cache-first, then database" logic. + var projectDataTask = GetProjectDataAsync(id); + + // Await both tasks to complete. + await Task.WhenAll(permissionTask, projectDataTask); + + var hasPermission = await permissionTask; + var projectVm = await projectDataTask; + + // --- Step 3: Process results sequentially --- + + // 3a. Check for permission first. Forbid() is the idiomatic way to return 403. + if (!hasPermission) + { + _logger.LogWarning("Access denied for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, id); + return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to access this project.", 403))); + } + + // 3b. Check if the project was found (either in cache or DB). + if (projectVm == null) + { + _logger.LogInfo("Project with ID {ProjectId} not found.", id); + return NotFound(ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404)); + } + + // 3c. Success. Return the consistent ViewModel. + _logger.LogInfo("Successfully retrieved project {ProjectId}.", id); + return Ok(ApiResponse.SuccessResponse(projectVm, "Project retrieved successfully.", 200)); + } + catch (Exception ex) + { + _logger.LogError("An unexpected error occurred while getting project {ProjectId} : \n {Error}", id, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); + } } + [HttpGet("details/{id}")] public async Task Details([FromRoute] Guid id) { @@ -1331,7 +1376,6 @@ namespace MarcoBMS.Services.Controllers return vm; } - /// /// Fetches project details from the database for a given list of project IDs and assembles them into MongoDB models. /// This method encapsulates the optimized, parallel database queries. @@ -1409,6 +1453,51 @@ namespace MarcoBMS.Services.Controllers return mongoDetailsList; } + /// + /// Private helper to encapsulate the cache-first data retrieval logic. + /// + /// A ProjectDetailVM if found, otherwise null. + private async Task GetProjectDataAsync(Guid projectId) + { + // --- Cache First --- + _logger.LogDebug("Attempting to fetch project {ProjectId} from cache.", projectId); + var cachedProject = await _cache.GetProjectDetails(projectId); + if (cachedProject != null) + { + _logger.LogInfo("Cache HIT for project {ProjectId}.", projectId); + // Map from the cache model (e.g., ProjectMongoDB) to the response ViewModel. + return _mapper.Map(cachedProject); + } + + // --- Database Second (on Cache Miss) --- + _logger.LogInfo("Cache MISS for project {ProjectId}. Fetching from database.", projectId); + var dbProject = await _context.Projects + .AsNoTracking() // Use AsNoTracking for read-only queries. + .Where(p => p.Id == projectId && p.TenantId == tenantId) + .SingleOrDefaultAsync(); + + if (dbProject == null) + { + return null; // The project doesn't exist. + } + + // --- Proactively Update Cache --- + // The next request for this project will now be a cache hit. + try + { + // Map the DB entity to the cache model (e.g., ProjectMongoDB) before caching. + await _cache.AddProjectDetails(dbProject); + _logger.LogInfo("Updated cache with project {ProjectId}.", projectId); + } + catch (Exception ex) + { + _logger.LogWarning("Failed to update cache for project {ProjectId} : \n {Error}", projectId, ex.Message); + } + + // Map from the database entity to the response ViewModel. + return dbProject; + } + #endregion } } \ No newline at end of file diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs index c7ec4af..f527f67 100644 --- a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs @@ -20,7 +20,18 @@ namespace Marco.Pms.Services.MappingProfiles .ForMember( dest => dest.Id, // Explicitly and safely convert string Id to Guid Id - opt => opt.MapFrom(src => src.Id == null ? Guid.Empty : new Guid(src.Id)) + opt => opt.MapFrom(src => new Guid(src.Id)) + ); + + CreateMap() + .ForMember( + dest => dest.Id, + // Explicitly and safely convert string Id to Guid Id + opt => opt.MapFrom(src => new Guid(src.Id)) + ).ForMember( + dest => dest.ProjectStatusId, + // Explicitly and safely convert string ProjectStatusId to Guid ProjectStatusId + opt => opt.MapFrom(src => src.ProjectStatus == null ? Guid.Empty : new Guid(src.ProjectStatus.Id)) ); CreateMap(); -- 2.43.0 From 36eb7aef7fcc6dd6f209f383c330bef1415366b8 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 14 Jul 2025 17:00:28 +0530 Subject: [PATCH 30/50] Optimized the Update project API --- .../Controllers/ProjectController.cs | 168 ++++++++++++++---- Marco.Pms.Services/Helpers/ProjectsHelper.cs | 6 +- .../MappingProfiles/ProjectMappingProfile.cs | 3 + 3 files changed, 142 insertions(+), 35 deletions(-) diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index acc97d2..3d5558f 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -70,7 +70,6 @@ namespace MarcoBMS.Services.Controllers _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); // Step 2: Get the list of project IDs the user has access to - Guid tenantId = _userHelper.GetTenantId(); // Assuming this is still needed by the helper List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); if (accessibleProjectIds == null || !accessibleProjectIds.Any()) @@ -316,7 +315,7 @@ namespace MarcoBMS.Services.Controllers } - var project = await _context.Projects.Where(c => c.TenantId == _userHelper.GetTenantId() && c.Id == id).Include(c => c.ProjectStatus).SingleOrDefaultAsync(); // includeProperties: "ProjectStatus,Tenant"); //_context.Stock.FindAsync(id); + var project = await _context.Projects.Where(c => c.TenantId == tenantId && c.Id == id).Include(c => c.ProjectStatus).SingleOrDefaultAsync(); // includeProperties: "ProjectStatus,Tenant"); //_context.Stock.FindAsync(id); if (project == null) { @@ -420,7 +419,6 @@ namespace MarcoBMS.Services.Controllers } // 2. Prepare data without I/O - Guid tenantId = _userHelper.GetTenantId(); // Assuming this is fast and from claims Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var loggedInUserId = loggedInEmployee.Id; var project = projectDto.ToProjectFromCreateProjectDto(tenantId); @@ -465,7 +463,7 @@ namespace MarcoBMS.Services.Controllers } [HttpPut] - [Route("update/{id}")] + [Route("update1/{id}")] public async Task Update([FromRoute] Guid id, [FromBody] UpdateProjectDto updateProjectDto) { var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); @@ -480,9 +478,7 @@ namespace MarcoBMS.Services.Controllers } try { - Guid TenantId = GetTenantId(); - - Project project = updateProjectDto.ToProjectFromUpdateProjectDto(TenantId, id); + Project project = updateProjectDto.ToProjectFromUpdateProjectDto(tenantId, id); _context.Projects.Update(project); await _context.SaveChangesAsync(); @@ -507,6 +503,97 @@ namespace MarcoBMS.Services.Controllers } } + /// + /// Updates an existing project's details. + /// This endpoint is secure, handles concurrency, and performs non-essential tasks in the background. + /// + /// The ID of the project to update. + /// The data to update the project with. + /// An ApiResponse confirming the update or an appropriate error. + + [HttpPut("update/{id}")] + public async Task UpdateProject([FromRoute] Guid id, [FromBody] UpdateProjectDto updateProjectDto) + { + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) + { + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Update project called with invalid model state for ID {ProjectId}. Errors: {Errors}", id, string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } + + try + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + // --- Step 2: Fetch the Existing Entity from the Database --- + // This is crucial to avoid the data loss bug. We only want to modify an existing record. + var existingProject = await _context.Projects + .Where(p => p.Id == id && p.TenantId == tenantId) + .SingleOrDefaultAsync(); + + // 2a. Existence Check + if (existingProject == null) + { + _logger.LogWarning("Attempt to update non-existent project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); + return NotFound(ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404)); + } + + // 2b. Security Check + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} attempting to update project {ProjectId}.", loggedInEmployee.Id, id); + return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to modify this project.", 403))); + } + + // --- Step 3: Apply Changes and Save --- + // Map the changes from the DTO onto the entity we just fetched from the database. + // This only modifies the properties defined in the mapping, preventing data loss. + _mapper.Map(updateProjectDto, existingProject); + + // Mark the entity as modified (if your mapping doesn't do it automatically). + _context.Entry(existingProject).State = EntityState.Modified; + + try + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully updated project {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); + } + catch (DbUpdateConcurrencyException ex) + { + // --- Step 4: Handle Concurrency Conflicts --- + // This happens if another user modified the project after we fetched it. + _logger.LogWarning("Concurrency conflict while updating project {ProjectId} \n {Error}", id, ex.Message); + return Conflict(ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409)); + } + + // --- Step 5: Perform Side-Effects in the Background (Fire and Forget) --- + // The core database operation is done. Now, we perform non-blocking cache and notification updates. + _ = Task.Run(async () => + { + // Create a DTO of the updated project to pass to background tasks. + var projectDto = _mapper.Map(existingProject); + + // 5a. Update Cache + await UpdateCacheInBackground(existingProject); + + // 5b. Send Targeted SignalR Notification + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Update_Project", Response = projectDto }; + await SendNotificationInBackground(notification, projectDto.Id); + }); + + // --- Step 6: Return Success Response Immediately --- + // The client gets a fast response without waiting for caching or SignalR. + return Ok(ApiResponse.SuccessResponse(_mapper.Map(existingProject), "Project updated successfully.", 200)); + } + catch (Exception ex) + { + // --- Step 7: Graceful Error Handling for Unexpected Errors --- + _logger.LogError("An unexpected error occurred while updating project {ProjectId} \n {Error}", id, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); + } + } + #endregion #region =================================================================== Project Allocation APIs =================================================================== @@ -524,7 +611,6 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } - Guid TenantId = GetTenantId(); if (projectid != null) { @@ -535,14 +621,14 @@ namespace MarcoBMS.Services.Controllers { result = await (from rpm in _context.Employees.Include(c => c.JobRole) - join fp in _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == projectid) + join fp in _context.ProjectAllocations.Where(c => c.TenantId == tenantId && c.ProjectId == projectid) on rpm.Id equals fp.EmployeeId select rpm).ToListAsync(); } else { result = await (from rpm in _context.Employees.Include(c => c.JobRole) - join fp in _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == projectid && c.IsActive == true) + join fp in _context.ProjectAllocations.Where(c => c.TenantId == tenantId && c.ProjectId == projectid && c.IsActive) on rpm.Id equals fp.EmployeeId select rpm).ToListAsync(); } @@ -577,11 +663,9 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } - Guid TenantId = GetTenantId(); - var employees = await _context.ProjectAllocations - .Where(c => c.TenantId == TenantId && c.ProjectId == projectId && c.Employee != null) + .Where(c => c.TenantId == tenantId && c.ProjectId == projectId && c.Employee != null) .Include(e => e.Employee) .Select(e => new { @@ -605,7 +689,6 @@ namespace MarcoBMS.Services.Controllers { if (projectAllocationDot != null) { - Guid TenentID = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); List? result = new List(); @@ -616,11 +699,11 @@ namespace MarcoBMS.Services.Controllers { try { - ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(TenentID); + ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == projectAllocation.EmployeeId && c.ProjectId == projectAllocation.ProjectId && c.ReAllocationDate == null - && c.TenantId == TenentID).SingleOrDefaultAsync(); + && c.TenantId == tenantId).SingleOrDefaultAsync(); if (projectAllocationFromDb != null) { @@ -688,8 +771,6 @@ namespace MarcoBMS.Services.Controllers [HttpGet("assigned-projects/{employeeId}")] public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) { - - Guid tenantId = _userHelper.GetTenantId(); if (employeeId == Guid.Empty) { return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Employee id not valid.", 400)); @@ -729,7 +810,6 @@ namespace MarcoBMS.Services.Controllers { if (projectAllocationDtos != null && employeeId != Guid.Empty) { - Guid TenentID = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); List? result = new List(); List projectIds = new List(); @@ -738,8 +818,8 @@ namespace MarcoBMS.Services.Controllers { try { - ProjectAllocation projectAllocation = projectAllocationDto.ToProjectAllocationFromProjectsAllocationDto(TenentID, employeeId); - ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.ProjectId == projectAllocationDto.ProjectId && c.ReAllocationDate == null && c.TenantId == TenentID).SingleOrDefaultAsync(); + ProjectAllocation projectAllocation = projectAllocationDto.ToProjectAllocationFromProjectsAllocationDto(tenantId, employeeId); + ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.ProjectId == projectAllocationDto.ProjectId && c.ReAllocationDate == null && c.TenantId == tenantId).SingleOrDefaultAsync(); if (projectAllocationFromDb != null) { @@ -1017,7 +1097,6 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Work Item details are not valid.", 400)); } - Guid tenantId = GetTenantId(); var workItemsToCreate = new List(); var workItemsToUpdate = new List(); var responseList = new List(); @@ -1113,7 +1192,6 @@ namespace MarcoBMS.Services.Controllers [HttpDelete("task/{id}")] public async Task DeleteProjectTask(Guid id) { - Guid tenantId = _userHelper.GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); List workAreaIds = new List(); WorkItem? task = await _context.WorkItems.AsNoTracking().Include(t => t.WorkArea).FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); @@ -1162,7 +1240,6 @@ namespace MarcoBMS.Services.Controllers [HttpPost("manage-infra")] public async Task ManageProjectInfra(List infraDots) { - Guid tenantId = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var responseData = new InfraVM { }; @@ -1177,7 +1254,7 @@ namespace MarcoBMS.Services.Controllers { Building building = item.Building.ToBuildingFromBuildingDto(tenantId); - building.TenantId = GetTenantId(); + building.TenantId = tenantId; if (item.Building.Id == null) { @@ -1204,7 +1281,7 @@ namespace MarcoBMS.Services.Controllers if (item.Floor != null) { Floor floor = item.Floor.ToFloorFromFloorDto(tenantId); - floor.TenantId = GetTenantId(); + floor.TenantId = tenantId; bool isCreated = false; if (item.Floor.Id == null) @@ -1242,7 +1319,7 @@ namespace MarcoBMS.Services.Controllers if (item.WorkArea != null) { WorkArea workArea = item.WorkArea.ToWorkAreaFromWorkAreaDto(tenantId); - workArea.TenantId = GetTenantId(); + workArea.TenantId = tenantId; bool isCreated = false; if (item.WorkArea.Id == null) @@ -1343,11 +1420,6 @@ namespace MarcoBMS.Services.Controllers return finalViewModels; } - private Guid GetTenantId() - { - return _userHelper.GetTenantId(); - } - private async Task GetProjectViewModel(Guid? id, Project project) { ProjectDetailsVM vm = new ProjectDetailsVM(); @@ -1498,6 +1570,38 @@ namespace MarcoBMS.Services.Controllers return dbProject; } + // Helper method for background cache update + private async Task UpdateCacheInBackground(Project project) + { + try + { + // This logic can be more complex, but the idea is to update or add. + if (!await _cache.UpdateProjectDetailsOnly(project)) + { + await _cache.AddProjectDetails(project); + } + _logger.LogInfo("Background cache update succeeded for project {ProjectId}.", project.Id); + } + catch (Exception ex) + { + _logger.LogError("Background cache update failed for project {ProjectId} \n {Error}", project.Id, ex.Message); + } + } + + // Helper method for background notification + private async Task SendNotificationInBackground(object notification, Guid projectId) + { + try + { + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + _logger.LogInfo("Background SignalR notification sent for project {ProjectId}.", projectId); + } + catch (Exception ex) + { + _logger.LogError("Background SignalR notification failed for project {ProjectId} \n {Error}", projectId, ex.Message); + } + } + #endregion } } \ No newline at end of file diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs index 6c1cab1..fe70a0a 100644 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ b/Marco.Pms.Services/Helpers/ProjectsHelper.cs @@ -67,11 +67,11 @@ namespace MarcoBMS.Services.Helpers else { var allocation = await GetProjectByEmployeeID(LoggedInEmployee.Id); - if (allocation.Any()) + if (!allocation.Any()) { - projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); + return new List(); } - return new List(); + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); } await _cache.AddProjects(LoggedInEmployee.Id, projectIds); } diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs index f527f67..18db7ff 100644 --- a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Master; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; @@ -14,7 +15,9 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); CreateMap(); + CreateMap(); CreateMap(); + CreateMap(); CreateMap(); CreateMap() .ForMember( -- 2.43.0 From f4ca7670e3b12a0309f106e3fc7c56b2af4eec3b Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 14 Jul 2025 18:45:23 +0530 Subject: [PATCH 31/50] Refactored: Moved business logic from ProjectController to ProjectService --- .../Controllers/ProjectController.cs | 693 +----------------- .../MappingProfiles/ProjectMappingProfile.cs | 1 + Marco.Pms.Services/Program.cs | 10 + .../Service/PermissionServices.cs | 10 +- Marco.Pms.Services/Service/ProjectServices.cs | 691 +++++++++++++++++ .../ServiceInterfaces/IProjectServices.cs | 17 + 6 files changed, 760 insertions(+), 662 deletions(-) create mode 100644 Marco.Pms.Services/Service/ProjectServices.cs create mode 100644 Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 3d5558f..e7d257f 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,6 +1,4 @@ -using AutoMapper; -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Activities; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; @@ -13,6 +11,7 @@ using Marco.Pms.Model.ViewModels.Projects; 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; @@ -28,30 +27,26 @@ namespace MarcoBMS.Services.Controllers [Authorize] public class ProjectController : ControllerBase { - private readonly IDbContextFactory _dbContextFactory; + private readonly IProjectServices _projectServices; private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly ILoggingService _logger; - private readonly ProjectsHelper _projectsHelper; private readonly IHubContext _signalR; private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; - private readonly IMapper _mapper; private readonly Guid tenantId; - public ProjectController(IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, - ProjectsHelper projectHelper, IHubContext signalR, CacheUpdateHelper cache, PermissionServices permission, IMapper mapper) + public ProjectController(ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, + IHubContext signalR, CacheUpdateHelper cache, PermissionServices permission, IProjectServices projectServices) { - _dbContextFactory = dbContextFactory; _context = context; _userHelper = userHelper; _logger = logger; - _projectsHelper = projectHelper; _signalR = signalR; _cache = cache; _permission = permission; - _mapper = mapper; + _projectServices = projectServices; tenantId = userHelper.GetTenantId(); } @@ -60,30 +55,10 @@ namespace MarcoBMS.Services.Controllers [HttpGet("list/basic")] public async Task GetAllProjectsBasic() { - // Step 1: Get the current user + // Get the current user var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - if (loggedInEmployee == null) - { - return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "User could not be identified.", 401)); - } - - _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); - - // Step 2: Get the list of project IDs the user has access to - List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - - if (accessibleProjectIds == null || !accessibleProjectIds.Any()) - { - _logger.LogInfo("No accessible projects found for EmployeeId {EmployeeId}", loggedInEmployee.Id); - return Ok(ApiResponse>.SuccessResponse(new List(), "Success.", 200)); - } - - // Step 3: Fetch project ViewModels using the optimized, cache-aware helper - var projectVMs = await GetProjectInfosByIdsAsync(accessibleProjectIds); - - // Step 4: Return the final list - _logger.LogInfo("Successfully returned {ProjectCount} projects for EmployeeId {EmployeeId}", projectVMs.Count, loggedInEmployee.Id); - return Ok(ApiResponse>.SuccessResponse(projectVMs, $"{projectVMs.Count} records of project fetchd successfully", 200)); + var response = await _projectServices.GetAllProjectsBasicAsync(tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } /// @@ -96,7 +71,7 @@ namespace MarcoBMS.Services.Controllers [HttpGet("list")] public async Task GetAllProjects() { - // --- Step 1: Input Validation and Initial Setup --- + // --- Input Validation and Initial Setup --- if (!ModelState.IsValid) { var errors = ModelState.Values @@ -106,63 +81,9 @@ namespace MarcoBMS.Services.Controllers _logger.LogWarning("GetAllProjects called with invalid model state. Errors: {Errors}", string.Join(", ", errors)); return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - - try - { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - _logger.LogInfo("Starting GetAllProjects for TenantId: {TenantId}, User: {UserId}", tenantId, loggedInEmployee.Id); - - // --- Step 2: Get a list of project IDs the user can access --- - List projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - if (!projectIds.Any()) - { - _logger.LogInfo("User has no assigned projects. Returning empty list."); - return Ok(ApiResponse>.SuccessResponse(new List(), "No projects found for the current user.", 200)); - } - - // --- Step 3: Efficiently handle partial cache hits --- - _logger.LogInfo("Attempting to fetch details for {ProjectCount} projects from cache.", projectIds.Count); - - // Fetch what we can from the cache. - var cachedDetails = await _cache.GetProjectDetailsList(projectIds) ?? new List(); - var cachedDictionary = cachedDetails.ToDictionary(p => Guid.Parse(p.Id)); - - // Identify which projects are missing from the cache. - var missingIds = projectIds.Where(id => !cachedDictionary.ContainsKey(id)).ToList(); - - // Start building the response with the items we found in the cache. - var responseVms = _mapper.Map>(cachedDictionary.Values); - - if (missingIds.Any()) - { - // --- Step 4: Fetch ONLY the missing items from the database --- - _logger.LogInfo("Cache partial MISS. Found {CachedCount}, fetching {MissingCount} projects from DB.", - cachedDictionary.Count, missingIds.Count); - - // Call our dedicated data-fetching method for the missing IDs. - var newMongoDetails = await FetchAndBuildProjectDetails(missingIds, tenantId); - - if (newMongoDetails.Any()) - { - // Map the newly fetched items and add them to our response list. - responseVms.AddRange(newMongoDetails); - } - } - else - { - _logger.LogInfo("Cache HIT. All {ProjectCount} projects found in cache.", projectIds.Count); - } - - // --- Step 5: Return the combined result --- - _logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", responseVms.Count); - return Ok(ApiResponse>.SuccessResponse(responseVms, "Projects retrieved successfully.", 200)); - } - catch (Exception ex) - { - // --- Step 6: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. \n {Error}", tenantId, ex.Message); - return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500)); - } + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetAllProjectsAsync(tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } /// @@ -173,7 +94,7 @@ namespace MarcoBMS.Services.Controllers /// An ApiResponse containing the project details or an appropriate error. [HttpGet("get/{id}")] - public async Task Get([FromRoute] Guid id) + public async Task GetProject([FromRoute] Guid id) { // --- Step 1: Input Validation --- if (!ModelState.IsValid) @@ -183,53 +104,14 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - try - { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // --- Step 2: Run independent operations in PARALLEL --- - // We can check permissions and fetch data at the same time to reduce latency. - var permissionTask = _permission.HasProjectPermission(loggedInEmployee, id); - - // This helper method encapsulates the "cache-first, then database" logic. - var projectDataTask = GetProjectDataAsync(id); - - // Await both tasks to complete. - await Task.WhenAll(permissionTask, projectDataTask); - - var hasPermission = await permissionTask; - var projectVm = await projectDataTask; - - // --- Step 3: Process results sequentially --- - - // 3a. Check for permission first. Forbid() is the idiomatic way to return 403. - if (!hasPermission) - { - _logger.LogWarning("Access denied for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, id); - return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to access this project.", 403))); - } - - // 3b. Check if the project was found (either in cache or DB). - if (projectVm == null) - { - _logger.LogInfo("Project with ID {ProjectId} not found.", id); - return NotFound(ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404)); - } - - // 3c. Success. Return the consistent ViewModel. - _logger.LogInfo("Successfully retrieved project {ProjectId}.", id); - return Ok(ApiResponse.SuccessResponse(projectVm, "Project retrieved successfully.", 200)); - } - catch (Exception ex) - { - _logger.LogError("An unexpected error occurred while getting project {ProjectId} : \n {Error}", id, ex.Message); - return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); - } + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectAsync(id, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpGet("details/{id}")] - public async Task Details([FromRoute] Guid id) + public async Task GetProjectDetails([FromRoute] Guid id) { // Step 1: Validate model state if (!ModelState.IsValid) @@ -245,63 +127,13 @@ namespace MarcoBMS.Services.Controllers // Step 2: Get logged-in employee var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - _logger.LogInfo("Details requested by EmployeeId: {EmployeeId} for ProjectId: {ProjectId}", loggedInEmployee.Id, id); - // Step 3: Check global view project permission - var hasViewProjectPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); - if (!hasViewProjectPermission) - { - _logger.LogWarning("ViewProjects permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have permission to view projects", 403)); - } - - // Step 4: Check permission for this specific project - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, id); - if (!hasProjectPermission) - { - _logger.LogWarning("Project-specific access denied. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployee.Id, id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403)); - } - - // Step 5: Fetch project with status - var projectDetails = await _cache.GetProjectDetails(id); - ProjectVM? projectVM = null; - if (projectDetails == null) - { - var project = await _context.Projects - .Include(c => c.ProjectStatus) - .FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id); - - projectVM = _mapper.Map(project); - - if (project != null) - { - await _cache.AddProjectDetails(project); - } - } - else - { - projectVM = _mapper.Map(projectDetails); - if (projectVM.ProjectStatus != null) - { - projectVM.ProjectStatus.TenantId = tenantId; - } - } - - if (projectVM == null) - { - _logger.LogWarning("Project not found. ProjectId: {ProjectId}", id); - return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); - } - - // Step 6: Return result - - _logger.LogInfo("Project details fetched successfully. ProjectId: {ProjectId}", id); - return Ok(ApiResponse.SuccessResponse(projectVM, "Project details fetched successfully", 200)); + var response = await _projectServices.GetProjectDetailsAsync(id, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpGet("details-old/{id}")] - public async Task DetailsOld([FromRoute] Guid id) + public async Task GetProjectDetailsOld([FromRoute] Guid id) { // ProjectDetailsVM vm = new ProjectDetailsVM(); @@ -315,92 +147,10 @@ namespace MarcoBMS.Services.Controllers } - var project = await _context.Projects.Where(c => c.TenantId == tenantId && c.Id == id).Include(c => c.ProjectStatus).SingleOrDefaultAsync(); // includeProperties: "ProjectStatus,Tenant"); //_context.Stock.FindAsync(id); - - if (project == null) - { - return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); - - } - else - { - //var project = projects.Where(c => c.Id == id).SingleOrDefault(); - ProjectDetailsVM vm = await GetProjectViewModel(id, project); - - OldProjectVM projectVM = new OldProjectVM(); - if (vm.project != null) - { - projectVM.Id = vm.project.Id; - projectVM.Name = vm.project.Name; - projectVM.ShortName = vm.project.ShortName; - projectVM.ProjectAddress = vm.project.ProjectAddress; - projectVM.ContactPerson = vm.project.ContactPerson; - projectVM.StartDate = vm.project.StartDate; - projectVM.EndDate = vm.project.EndDate; - projectVM.ProjectStatusId = vm.project.ProjectStatusId; - } - projectVM.Buildings = new List(); - if (vm.buildings != null) - { - foreach (Building build in vm.buildings) - { - BuildingVM buildVM = new BuildingVM() { Id = build.Id, Description = build.Description, Name = build.Name }; - buildVM.Floors = new List(); - if (vm.floors != null) - { - foreach (Floor floorDto in vm.floors.Where(c => c.BuildingId == build.Id).ToList()) - { - FloorsVM floorVM = new FloorsVM() { FloorName = floorDto.FloorName, Id = floorDto.Id }; - floorVM.WorkAreas = new List(); - - if (vm.workAreas != null) - { - foreach (WorkArea workAreaDto in vm.workAreas.Where(c => c.FloorId == floorVM.Id).ToList()) - { - WorkAreaVM workAreaVM = new WorkAreaVM() { Id = workAreaDto.Id, AreaName = workAreaDto.AreaName, WorkItems = new List() }; - - if (vm.workItems != null) - { - foreach (WorkItem workItemDto in vm.workItems.Where(c => c.WorkAreaId == workAreaDto.Id).ToList()) - { - WorkItemVM workItemVM = new WorkItemVM() { WorkItemId = workItemDto.Id, WorkItem = workItemDto }; - - workItemVM.WorkItem.WorkArea = new WorkArea(); - - if (workItemVM.WorkItem.ActivityMaster != null) - { - workItemVM.WorkItem.ActivityMaster.Tenant = new Tenant(); - } - workItemVM.WorkItem.Tenant = new Tenant(); - - double todaysAssigned = 0; - if (vm.Tasks != null) - { - var tasks = vm.Tasks.Where(t => t.WorkItemId == workItemDto.Id).ToList(); - foreach (TaskAllocation task in tasks) - { - todaysAssigned += task.PlannedTask; - } - } - workItemVM.TodaysAssigned = todaysAssigned; - - workAreaVM.WorkItems.Add(workItemVM); - } - } - - floorVM.WorkAreas.Add(workAreaVM); - } - } - - buildVM.Floors.Add(floorVM); - } - } - projectVM.Buildings.Add(buildVM); - } - } - return Ok(ApiResponse.SuccessResponse(projectVM, "Success.", 200)); - } + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectDetailsAsync(id, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } @@ -409,7 +159,7 @@ namespace MarcoBMS.Services.Controllers #region =================================================================== Project Manage APIs =================================================================== [HttpPost] - public async Task Create([FromBody] CreateProjectDto projectDto) + public async Task CreateProject([FromBody] CreateProjectDto projectDto) { // 1. Validate input first (early exit) if (!ModelState.IsValid) @@ -420,87 +170,13 @@ namespace MarcoBMS.Services.Controllers // 2. Prepare data without I/O Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var loggedInUserId = loggedInEmployee.Id; - var project = projectDto.ToProjectFromCreateProjectDto(tenantId); - - // 3. Store it to database - try + var response = await _projectServices.CreateProjectAsync(projectDto, tenantId, loggedInEmployee); + if (response.Success) { - _context.Projects.Add(project); - await _context.SaveChangesAsync(); - } - catch (Exception ex) - { - // Log the detailed exception - _logger.LogError("Failed to create project in database. Rolling back transaction. : {Error}", ex.Message); - // Return a server error as the primary operation failed - return StatusCode(500, ApiResponse.ErrorResponse("An error occurred while saving the project.", ex.Message, 500)); - } - - // 4. Perform non-critical side-effects (caching, notifications) concurrently - try - { - // These operations do not depend on each other, so they can run in parallel. - Task cacheAddDetailsTask = _cache.AddProjectDetails(project); - Task cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(PermissionsMaster.ManageProject); - - var notification = new { LoggedInUserId = loggedInUserId, Keyword = "Create_Project", Response = project.ToProjectDto() }; - // Send notification only to the relevant group (e.g., users in the same tenant) - Task notificationTask = _signalR.Clients.Group(tenantId.ToString()).SendAsync("NotificationEventHandler", notification); - - // Await all side-effect tasks to complete in parallel - await Task.WhenAll(cacheAddDetailsTask, cacheClearListTask, notificationTask); - } - catch (Exception ex) - { - // The project was created successfully, but a side-effect failed. - // Log this as a warning, as the primary operation succeeded. Don't return an error to the user. - _logger.LogWarning("Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. : {Error}", project.Id, ex.Message); - } - - // 5. Return a success response to the user as soon as the critical data is saved. - return Ok(ApiResponse.SuccessResponse(project.ToProjectDto(), "Project created successfully.", 200)); - } - - [HttpPut] - [Route("update1/{id}")] - public async Task Update([FromRoute] Guid id, [FromBody] UpdateProjectDto updateProjectDto) - { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - if (!ModelState.IsValid) - { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - - } - try - { - Project project = updateProjectDto.ToProjectFromUpdateProjectDto(tenantId, id); - _context.Projects.Update(project); - - await _context.SaveChangesAsync(); - - // Cache functions - bool isUpdated = await _cache.UpdateProjectDetailsOnly(project); - if (!isUpdated) - { - await _cache.AddProjectDetails(project); - } - - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Update_Project", Response = project.ToProjectDto() }; - + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Create_Project", Response = response.Data }; await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - - return Ok(ApiResponse.SuccessResponse(project.ToProjectDto(), "Success.", 200)); - - } - catch (Exception ex) - { - return BadRequest(ApiResponse.ErrorResponse(ex.Message, ex, 400)); } + return StatusCode(response.StatusCode, response); } /// @@ -522,76 +198,15 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - try + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.UpdateProjectAsync(id, updateProjectDto, tenantId, loggedInEmployee); + if (response.Success) { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - // --- Step 2: Fetch the Existing Entity from the Database --- - // This is crucial to avoid the data loss bug. We only want to modify an existing record. - var existingProject = await _context.Projects - .Where(p => p.Id == id && p.TenantId == tenantId) - .SingleOrDefaultAsync(); - - // 2a. Existence Check - if (existingProject == null) - { - _logger.LogWarning("Attempt to update non-existent project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); - return NotFound(ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404)); - } - - // 2b. Security Check - var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id); - if (!hasPermission) - { - _logger.LogWarning("Access DENIED for user {UserId} attempting to update project {ProjectId}.", loggedInEmployee.Id, id); - return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to modify this project.", 403))); - } - - // --- Step 3: Apply Changes and Save --- - // Map the changes from the DTO onto the entity we just fetched from the database. - // This only modifies the properties defined in the mapping, preventing data loss. - _mapper.Map(updateProjectDto, existingProject); - - // Mark the entity as modified (if your mapping doesn't do it automatically). - _context.Entry(existingProject).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - _logger.LogInfo("Successfully updated project {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); - } - catch (DbUpdateConcurrencyException ex) - { - // --- Step 4: Handle Concurrency Conflicts --- - // This happens if another user modified the project after we fetched it. - _logger.LogWarning("Concurrency conflict while updating project {ProjectId} \n {Error}", id, ex.Message); - return Conflict(ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409)); - } - - // --- Step 5: Perform Side-Effects in the Background (Fire and Forget) --- - // The core database operation is done. Now, we perform non-blocking cache and notification updates. - _ = Task.Run(async () => - { - // Create a DTO of the updated project to pass to background tasks. - var projectDto = _mapper.Map(existingProject); - - // 5a. Update Cache - await UpdateCacheInBackground(existingProject); - - // 5b. Send Targeted SignalR Notification - var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Update_Project", Response = projectDto }; - await SendNotificationInBackground(notification, projectDto.Id); - }); - - // --- Step 6: Return Success Response Immediately --- - // The client gets a fast response without waiting for caching or SignalR. - return Ok(ApiResponse.SuccessResponse(_mapper.Map(existingProject), "Project updated successfully.", 200)); - } - catch (Exception ex) - { - // --- Step 7: Graceful Error Handling for Unexpected Errors --- - _logger.LogError("An unexpected error occurred while updating project {ProjectId} \n {Error}", id, ex.Message); - return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Update_Project", Response = response.Data }; + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); } + return StatusCode(response.StatusCode, response); } #endregion @@ -1367,241 +982,5 @@ namespace MarcoBMS.Services.Controllers #endregion - #region =================================================================== Helper Functions =================================================================== - - /// - /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. - /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the - /// database (as Project), updates the cache, and returns a unified list of ViewModels. - /// - /// The list of project IDs to retrieve. - /// A list of ProjectInfoVMs. - private async Task> GetProjectInfosByIdsAsync(List projectIds) - { - // --- Step 1: Fetch from Cache --- - // The cache returns a list of MongoDB documents for the projects it found. - var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); - var finalViewModels = _mapper.Map>(cachedMongoDocs); - - _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); - - // --- Step 2: Identify Missing Projects --- - // If we found everything in the cache, we can return early. - if (finalViewModels.Count == projectIds.Count) - { - return finalViewModels; - } - - var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id - var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); - - // --- Step 3: Fetch Missing from Database --- - if (missingIds.Any()) - { - _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); - - var projectsFromDb = await _context.Projects - .Where(p => missingIds.Contains(p.Id)) - .AsNoTracking() // Use AsNoTracking for read-only query performance - .ToListAsync(); - - if (projectsFromDb.Any()) - { - // Map the newly fetched projects (from SQL) to their ViewModel - var vmsFromDb = _mapper.Map>(projectsFromDb); - finalViewModels.AddRange(vmsFromDb); - - // --- Step 4: Update Cache with Missing Items in a new scope --- - _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); - await _cache.AddProjectDetailsList(projectsFromDb); - } - } - - return finalViewModels; - } - - private async Task GetProjectViewModel(Guid? id, Project project) - { - ProjectDetailsVM vm = new ProjectDetailsVM(); - - // List buildings = _unitOfWork.Building.GetAll(c => c.ProjectId == id).ToList(); - List buildings = await _context.Buildings.Where(c => c.ProjectId == id).ToListAsync(); - List idList = buildings.Select(o => o.Id).ToList(); - // List floors = _unitOfWork.Floor.GetAll(c => idList.Contains(c.Id)).ToList(); - List floors = await _context.Floor.Where(c => idList.Contains(c.BuildingId)).ToListAsync(); - idList = floors.Select(o => o.Id).ToList(); - //List workAreas = _unitOfWork.WorkArea.GetAll(c => idList.Contains(c.Id), includeProperties: "WorkItems,WorkItems.ActivityMaster").ToList(); - - List workAreas = await _context.WorkAreas.Where(c => idList.Contains(c.FloorId)).ToListAsync(); - - idList = workAreas.Select(o => o.Id).ToList(); - List workItems = await _context.WorkItems.Include(c => c.WorkCategoryMaster).Where(c => idList.Contains(c.WorkAreaId)).Include(c => c.ActivityMaster).ToListAsync(); - // List workItems = _unitOfWork.WorkItem.GetAll(c => idList.Contains(c.WorkAreaId), includeProperties: "ActivityMaster").ToList(); - idList = workItems.Select(t => t.Id).ToList(); - List tasks = await _context.TaskAllocations.Where(t => idList.Contains(t.WorkItemId) && t.AssignmentDate.Date == DateTime.UtcNow.Date).ToListAsync(); - vm.project = project; - vm.buildings = buildings; - vm.floors = floors; - vm.workAreas = workAreas; - vm.workItems = workItems; - vm.Tasks = tasks; - return vm; - } - - /// - /// Fetches project details from the database for a given list of project IDs and assembles them into MongoDB models. - /// This method encapsulates the optimized, parallel database queries. - /// - /// The list of project IDs to fetch. - /// The current tenant ID for filtering. - /// A list of fully populated ProjectMongoDB objects. - private async Task> FetchAndBuildProjectDetails(List projectIdsToFetch, Guid tenantId) - { - // Task to get base project details for the MISSING projects - var projectsTask = Task.Run(async () => - { - using var context = _dbContextFactory.CreateDbContext(); - return await context.Projects.AsNoTracking() - .Where(p => projectIdsToFetch.Contains(p.Id) && p.TenantId == tenantId) - .ToListAsync(); - }); - - // Task to get team sizes for the MISSING projects - var teamSizesTask = Task.Run(async () => - { - using var context = _dbContextFactory.CreateDbContext(); - return await context.ProjectAllocations.AsNoTracking() - .Where(pa => pa.TenantId == tenantId && projectIdsToFetch.Contains(pa.ProjectId) && pa.IsActive) - .GroupBy(pa => pa.ProjectId) - .Select(g => new { ProjectId = g.Key, Count = g.Count() }) - .ToDictionaryAsync(x => x.ProjectId, x => x.Count); - }); - - // Task to get work summaries for the MISSING projects - var workSummariesTask = Task.Run(async () => - { - using var context = _dbContextFactory.CreateDbContext(); - return await context.WorkItems.AsNoTracking() - .Where(wi => wi.TenantId == tenantId && - wi.WorkArea != null && - wi.WorkArea.Floor != null && - wi.WorkArea.Floor.Building != null && - projectIdsToFetch.Contains(wi.WorkArea.Floor.Building.ProjectId)) - .GroupBy(wi => wi.WorkArea!.Floor!.Building!.ProjectId) - .Select(g => new { ProjectId = g.Key, PlannedWork = g.Sum(i => i.PlannedWork), CompletedWork = g.Sum(i => i.CompletedWork) }) - .ToDictionaryAsync(x => x.ProjectId); - }); - - // Await all parallel tasks to complete - await Task.WhenAll(projectsTask, teamSizesTask, workSummariesTask); - - var projects = await projectsTask; - var teamSizes = await teamSizesTask; - var workSummaries = await workSummariesTask; - - // Proactively update the cache with the items we just fetched. - _logger.LogInfo("Updating cache with {NewItemCount} newly fetched projects.", projects.Count); - await _cache.AddProjectDetailsList(projects); - - // This section would build the full ProjectMongoDB objects, similar to your AddProjectDetailsList method. - // For brevity, assuming you have a mapper or a builder for this. Here's a simplified representation: - var mongoDetailsList = new List(); - foreach (var project in projects) - { - // This is a placeholder for the full build logic from your other methods. - // In a real scenario, you would fetch all hierarchy levels (buildings, floors, etc.) - // for the `projectIdsToFetch` and build the complete MongoDB object. - var mongoDetail = _mapper.Map(project); - mongoDetail.Id = project.Id; - mongoDetail.TeamSize = teamSizes.GetValueOrDefault(project.Id, 0); - if (workSummaries.TryGetValue(project.Id, out var summary)) - { - mongoDetail.PlannedWork = summary.PlannedWork; - mongoDetail.CompletedWork = summary.CompletedWork; - } - mongoDetailsList.Add(mongoDetail); - } - - return mongoDetailsList; - } - - /// - /// Private helper to encapsulate the cache-first data retrieval logic. - /// - /// A ProjectDetailVM if found, otherwise null. - private async Task GetProjectDataAsync(Guid projectId) - { - // --- Cache First --- - _logger.LogDebug("Attempting to fetch project {ProjectId} from cache.", projectId); - var cachedProject = await _cache.GetProjectDetails(projectId); - if (cachedProject != null) - { - _logger.LogInfo("Cache HIT for project {ProjectId}.", projectId); - // Map from the cache model (e.g., ProjectMongoDB) to the response ViewModel. - return _mapper.Map(cachedProject); - } - - // --- Database Second (on Cache Miss) --- - _logger.LogInfo("Cache MISS for project {ProjectId}. Fetching from database.", projectId); - var dbProject = await _context.Projects - .AsNoTracking() // Use AsNoTracking for read-only queries. - .Where(p => p.Id == projectId && p.TenantId == tenantId) - .SingleOrDefaultAsync(); - - if (dbProject == null) - { - return null; // The project doesn't exist. - } - - // --- Proactively Update Cache --- - // The next request for this project will now be a cache hit. - try - { - // Map the DB entity to the cache model (e.g., ProjectMongoDB) before caching. - await _cache.AddProjectDetails(dbProject); - _logger.LogInfo("Updated cache with project {ProjectId}.", projectId); - } - catch (Exception ex) - { - _logger.LogWarning("Failed to update cache for project {ProjectId} : \n {Error}", projectId, ex.Message); - } - - // Map from the database entity to the response ViewModel. - return dbProject; - } - - // Helper method for background cache update - private async Task UpdateCacheInBackground(Project project) - { - try - { - // This logic can be more complex, but the idea is to update or add. - if (!await _cache.UpdateProjectDetailsOnly(project)) - { - await _cache.AddProjectDetails(project); - } - _logger.LogInfo("Background cache update succeeded for project {ProjectId}.", project.Id); - } - catch (Exception ex) - { - _logger.LogError("Background cache update failed for project {ProjectId} \n {Error}", project.Id, ex.Message); - } - } - - // Helper method for background notification - private async Task SendNotificationInBackground(object notification, Guid projectId) - { - try - { - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - _logger.LogInfo("Background SignalR notification sent for project {ProjectId}.", projectId); - } - catch (Exception ex) - { - _logger.LogError("Background SignalR notification failed for project {ProjectId} \n {Error}", projectId, ex.Message); - } - } - - #endregion } } \ No newline at end of file diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs index 18db7ff..b811056 100644 --- a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs @@ -39,6 +39,7 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); + CreateMap(); } } } diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 7fa2647..6553745 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -6,6 +6,7 @@ using Marco.Pms.Model.Utilities; 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.Middleware; using MarcoBMS.Services.Service; @@ -154,8 +155,13 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); // Scoped services (one instance per HTTP request) +#region Customs Services builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +#endregion + +#region Helpers builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -164,9 +170,13 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +#endregion + +#region Cache Services builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +#endregion // Singleton services (one instance for the app's lifetime) builder.Services.AddSingleton(); diff --git a/Marco.Pms.Services/Service/PermissionServices.cs b/Marco.Pms.Services/Service/PermissionServices.cs index f20a768..9758a5f 100644 --- a/Marco.Pms.Services/Service/PermissionServices.cs +++ b/Marco.Pms.Services/Service/PermissionServices.cs @@ -37,7 +37,7 @@ namespace Marco.Pms.Services.Service if (projectIds == null) { - var hasPermission = await HasPermission(employeeId, PermissionsMaster.ManageProject); + var hasPermission = await HasPermission(PermissionsMaster.ManageProject, employeeId); if (hasPermission) { var projects = await _context.Projects.Where(c => c.TenantId == LoggedInEmployee.TenantId).ToListAsync(); @@ -45,12 +45,12 @@ namespace Marco.Pms.Services.Service } else { - var allocation = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive == true).ToListAsync(); - if (allocation.Any()) + var allocation = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive).ToListAsync(); + if (!allocation.Any()) { - projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); + return false; } - return false; + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); } await _cache.AddProjects(LoggedInEmployee.Id, projectIds); } diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs new file mode 100644 index 0000000..3280558 --- /dev/null +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -0,0 +1,691 @@ +using AutoMapper; +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.Activities; +using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Entitlements; +using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.Projects; +using Marco.Pms.Model.Utilities; +using Marco.Pms.Model.ViewModels.Projects; +using Marco.Pms.Services.Helpers; +using Marco.Pms.Services.Service.ServiceInterfaces; +using MarcoBMS.Services.Helpers; +using MarcoBMS.Services.Service; +using Microsoft.EntityFrameworkCore; + +namespace Marco.Pms.Services.Service +{ + public class ProjectServices : IProjectServices + { + private readonly IDbContextFactory _dbContextFactory; + private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate + private readonly ILoggingService _logger; + private readonly ProjectsHelper _projectsHelper; + private readonly PermissionServices _permission; + private readonly CacheUpdateHelper _cache; + private readonly IMapper _mapper; + public ProjectServices( + IDbContextFactory dbContextFactory, + ApplicationDbContext context, + ILoggingService logger, + ProjectsHelper projectsHelper, + PermissionServices permission, + CacheUpdateHelper cache, + IMapper mapper) + { + _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _projectsHelper = projectsHelper ?? throw new ArgumentNullException(nameof(projectsHelper)); + _permission = permission ?? throw new ArgumentNullException(nameof(permission)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + #region =================================================================== Project Get APIs =================================================================== + + public async Task> GetAllProjectsBasicAsync(Guid tenantId, Employee loggedInEmployee) + { + try + { + // Step 1: Verify the current user + if (loggedInEmployee == null) + { + return ApiResponse.ErrorResponse("Unauthorized", "User could not be identified.", 401); + } + + _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); + + // Step 2: Get the list of project IDs the user has access to + List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + + if (accessibleProjectIds == null || !accessibleProjectIds.Any()) + { + _logger.LogInfo("No accessible projects found for EmployeeId {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.SuccessResponse(new List(), "0 records of project fetchd successfully", 200); + } + + // Step 3: Fetch project ViewModels using the optimized, cache-aware helper + var projectVMs = await GetProjectInfosByIdsAsync(accessibleProjectIds); + + // Step 4: Return the final list + _logger.LogInfo("Successfully returned {ProjectCount} projects for EmployeeId {EmployeeId}", projectVMs.Count, loggedInEmployee.Id); + return ApiResponse.SuccessResponse(projectVMs, $"{projectVMs.Count} records of project fetchd successfully", 200); + } + catch (Exception ex) + { + // --- Step 5: Graceful Error Handling --- + _logger.LogError("An unexpected error occurred in GetAllProjectsBasic for tenant {TenantId}. \n {Error}", tenantId, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); + } + } + + public async Task> GetAllProjectsAsync(Guid tenantId, Employee loggedInEmployee) + { + try + { + _logger.LogInfo("Starting GetAllProjects for TenantId: {TenantId}, User: {UserId}", tenantId, loggedInEmployee.Id); + + // --- Step 1: Get a list of project IDs the user can access --- + List projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + if (!projectIds.Any()) + { + _logger.LogInfo("User has no assigned projects. Returning empty list."); + return ApiResponse.SuccessResponse(new List(), "No projects found for the current user.", 200); + } + + // --- Step 2: Efficiently handle partial cache hits --- + _logger.LogInfo("Attempting to fetch details for {ProjectCount} projects from cache.", projectIds.Count); + + // Fetch what we can from the cache. + var cachedDetails = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var cachedDictionary = cachedDetails.ToDictionary(p => Guid.Parse(p.Id)); + + // Identify which projects are missing from the cache. + var missingIds = projectIds.Where(id => !cachedDictionary.ContainsKey(id)).ToList(); + + // Start building the response with the items we found in the cache. + var responseVms = _mapper.Map>(cachedDictionary.Values); + + if (missingIds.Any()) + { + // --- Step 3: Fetch ONLY the missing items from the database --- + _logger.LogInfo("Cache partial MISS. Found {CachedCount}, fetching {MissingCount} projects from DB.", + cachedDictionary.Count, missingIds.Count); + + // Call our dedicated data-fetching method for the missing IDs. + var newMongoDetails = await FetchAndBuildProjectDetails(missingIds, tenantId); + + if (newMongoDetails.Any()) + { + // Map the newly fetched items and add them to our response list. + responseVms.AddRange(newMongoDetails); + } + } + else + { + _logger.LogInfo("Cache HIT. All {ProjectCount} projects found in cache.", projectIds.Count); + } + + // --- Step 4: Return the combined result --- + _logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", responseVms.Count); + return ApiResponse.SuccessResponse(responseVms, "Projects retrieved successfully.", 200); + } + catch (Exception ex) + { + // --- Step 5: Graceful Error Handling --- + _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. \n {Error}", tenantId, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); + } + } + + public async Task> GetProjectAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + try + { + // --- Step 1: Run independent operations in PARALLEL --- + // We can check permissions and fetch data at the same time to reduce latency. + var permissionTask = _permission.HasProjectPermission(loggedInEmployee, id); + + // This helper method encapsulates the "cache-first, then database" logic. + var projectDataTask = GetProjectDataAsync(id, tenantId); + + // Await both tasks to complete. + await Task.WhenAll(permissionTask, projectDataTask); + + var hasPermission = await permissionTask; + var projectVm = await projectDataTask; + + // --- Step 2: Process results sequentially --- + + // 2a. Check for permission first. Forbid() is the idiomatic way to return 403. + if (!hasPermission) + { + _logger.LogWarning("Access denied for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to access this project.", 403); + } + + // 2b. Check if the project was found (either in cache or DB). + if (projectVm == null) + { + _logger.LogInfo("Project with ID {ProjectId} not found.", id); + return ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404); + } + + // 2c. Success. Return the consistent ViewModel. + _logger.LogInfo("Successfully retrieved project {ProjectId}.", id); + return ApiResponse.SuccessResponse(projectVm, "Project retrieved successfully.", 200); + } + catch (Exception ex) + { + _logger.LogError("An unexpected error occurred while getting project {ProjectId} : \n {Error}", id, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); + } + } + + public async Task> GetProjectDetailsAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + try + { + _logger.LogInfo("Details requested by EmployeeId: {EmployeeId} for ProjectId: {ProjectId}", loggedInEmployee.Id, id); + + // Step 1: Check global view project permission + var hasViewProjectPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); + if (!hasViewProjectPermission) + { + _logger.LogWarning("ViewProjects permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Access denied", "You don't have permission to view projects", 403); + } + + // Step 2: Check permission for this specific project + var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, id); + if (!hasProjectPermission) + { + _logger.LogWarning("Project-specific access denied. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403); + } + + // Step 3: Fetch project with status + var projectDetails = await _cache.GetProjectDetails(id); + ProjectVM? projectVM = null; + if (projectDetails == null) + { + var project = await _context.Projects + .Include(c => c.ProjectStatus) + .FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id); + + projectVM = _mapper.Map(project); + + if (project != null) + { + await _cache.AddProjectDetails(project); + } + } + else + { + projectVM = _mapper.Map(projectDetails); + if (projectVM.ProjectStatus != null) + { + projectVM.ProjectStatus.TenantId = tenantId; + } + } + + if (projectVM == null) + { + _logger.LogWarning("Project not found. ProjectId: {ProjectId}", id); + return ApiResponse.ErrorResponse("Project not found", "Project not found", 404); + } + + // Step 4: Return result + + _logger.LogInfo("Project details fetched successfully. ProjectId: {ProjectId}", id); + return ApiResponse.SuccessResponse(projectVM, "Project details fetched successfully", 200); + } + catch (Exception ex) + { + // --- Step 5: Graceful Error Handling --- + _logger.LogError("An unexpected error occurred in Get Project Details for project {ProjectId} for tenant {TenantId}. \n {Error}", id, tenantId, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); + } + } + + public async Task> GetProjectDetailsOldAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + var project = await _context.Projects + .Where(c => c.TenantId == tenantId && c.Id == id) + .Include(c => c.ProjectStatus) + .SingleOrDefaultAsync(); + + if (project == null) + { + return ApiResponse.ErrorResponse("Project not found", "Project not found", 404); + + } + else + { + ProjectDetailsVM vm = await GetProjectViewModel(id, project); + + OldProjectVM projectVM = new OldProjectVM(); + if (vm.project != null) + { + projectVM.Id = vm.project.Id; + projectVM.Name = vm.project.Name; + projectVM.ShortName = vm.project.ShortName; + projectVM.ProjectAddress = vm.project.ProjectAddress; + projectVM.ContactPerson = vm.project.ContactPerson; + projectVM.StartDate = vm.project.StartDate; + projectVM.EndDate = vm.project.EndDate; + projectVM.ProjectStatusId = vm.project.ProjectStatusId; + } + projectVM.Buildings = new List(); + if (vm.buildings != null) + { + foreach (Building build in vm.buildings) + { + BuildingVM buildVM = new BuildingVM() { Id = build.Id, Description = build.Description, Name = build.Name }; + buildVM.Floors = new List(); + if (vm.floors != null) + { + foreach (Floor floorDto in vm.floors.Where(c => c.BuildingId == build.Id).ToList()) + { + FloorsVM floorVM = new FloorsVM() { FloorName = floorDto.FloorName, Id = floorDto.Id }; + floorVM.WorkAreas = new List(); + + if (vm.workAreas != null) + { + foreach (WorkArea workAreaDto in vm.workAreas.Where(c => c.FloorId == floorVM.Id).ToList()) + { + WorkAreaVM workAreaVM = new WorkAreaVM() { Id = workAreaDto.Id, AreaName = workAreaDto.AreaName, WorkItems = new List() }; + + if (vm.workItems != null) + { + foreach (WorkItem workItemDto in vm.workItems.Where(c => c.WorkAreaId == workAreaDto.Id).ToList()) + { + WorkItemVM workItemVM = new WorkItemVM() { WorkItemId = workItemDto.Id, WorkItem = workItemDto }; + + workItemVM.WorkItem.WorkArea = new WorkArea(); + + if (workItemVM.WorkItem.ActivityMaster != null) + { + workItemVM.WorkItem.ActivityMaster.Tenant = new Tenant(); + } + workItemVM.WorkItem.Tenant = new Tenant(); + + double todaysAssigned = 0; + if (vm.Tasks != null) + { + var tasks = vm.Tasks.Where(t => t.WorkItemId == workItemDto.Id).ToList(); + foreach (TaskAllocation task in tasks) + { + todaysAssigned += task.PlannedTask; + } + } + workItemVM.TodaysAssigned = todaysAssigned; + + workAreaVM.WorkItems.Add(workItemVM); + } + } + + floorVM.WorkAreas.Add(workAreaVM); + } + } + + buildVM.Floors.Add(floorVM); + } + } + projectVM.Buildings.Add(buildVM); + } + } + return ApiResponse.SuccessResponse(projectVM, "Success.", 200); + } + } + + #endregion + + #region =================================================================== Project Manage APIs =================================================================== + + public async Task> CreateProjectAsync(CreateProjectDto projectDto, Guid tenantId, Employee loggedInEmployee) + { + // 1. Prepare data without I/O + var loggedInUserId = loggedInEmployee.Id; + var project = _mapper.Map(projectDto); + project.TenantId = tenantId; + + // 2. Store it to database + try + { + _context.Projects.Add(project); + await _context.SaveChangesAsync(); + } + catch (Exception ex) + { + // Log the detailed exception + _logger.LogError("Failed to create project in database. Rolling back transaction. \n {Error}", ex.Message); + // Return a server error as the primary operation failed + return ApiResponse.ErrorResponse("An error occurred while saving the project.", ex.Message, 500); + } + + // 3. Perform non-critical side-effects (caching, notifications) concurrently + try + { + // These operations do not depend on each other, so they can run in parallel. + Task cacheAddDetailsTask = _cache.AddProjectDetails(project); + Task cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(PermissionsMaster.ManageProject); + + // Await all side-effect tasks to complete in parallel + await Task.WhenAll(cacheAddDetailsTask, cacheClearListTask); + } + catch (Exception ex) + { + // The project was created successfully, but a side-effect failed. + // Log this as a warning, as the primary operation succeeded. Don't return an error to the user. + _logger.LogWarning("Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. \n {Error}", project.Id, ex.Message); + } + + // 4. Return a success response to the user as soon as the critical data is saved. + return ApiResponse.SuccessResponse(_mapper.Map(project), "Project created successfully.", 200); + } + + /// + /// Updates an existing project's details. + /// This endpoint is secure, handles concurrency, and performs non-essential tasks in the background. + /// + /// The ID of the project to update. + /// The data to update the project with. + /// An ApiResponse confirming the update or an appropriate error. + public async Task> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee) + { + try + { + // --- Step 1: Fetch the Existing Entity from the Database --- + // This is crucial to avoid the data loss bug. We only want to modify an existing record. + var existingProject = await _context.Projects + .Where(p => p.Id == id && p.TenantId == tenantId) + .SingleOrDefaultAsync(); + + // 1a. Existence Check + if (existingProject == null) + { + _logger.LogWarning("Attempt to update non-existent project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404); + } + + // 1b. Security Check + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} attempting to update project {ProjectId}.", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to modify this project.", 403); + } + + // --- Step 2: Apply Changes and Save --- + // Map the changes from the DTO onto the entity we just fetched from the database. + // This only modifies the properties defined in the mapping, preventing data loss. + _mapper.Map(updateProjectDto, existingProject); + + // Mark the entity as modified (if your mapping doesn't do it automatically). + _context.Entry(existingProject).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.LogWarning("Concurrency conflict while updating project {ProjectId} \n {Error}", id, ex.Message); + return ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409); + } + + // --- Step 4: Perform Side-Effects in the Background (Fire and Forget) --- + // The core database operation is done. Now, we perform non-blocking cache and notification updates. + _ = Task.Run(async () => + { + // Create a DTO of the updated project to pass to background tasks. + var projectDto = _mapper.Map(existingProject); + + // 4a. Update Cache + await UpdateCacheInBackground(existingProject); + + }); + + // --- Step 5: Return Success Response Immediately --- + // The client gets a fast response without waiting for caching or SignalR. + return ApiResponse.SuccessResponse(_mapper.Map(existingProject), "Project updated successfully.", 200); + } + catch (Exception ex) + { + // --- Step 6: Graceful Error Handling for Unexpected Errors --- + _logger.LogError("An unexpected error occurred while updating project {ProjectId} \n {Error}", id, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); + } + } + + #endregion + + #region =================================================================== Helper Functions =================================================================== + + /// + /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. + /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the + /// database (as Project), updates the cache, and returns a unified list of ViewModels. + /// + /// The list of project IDs to retrieve. + /// A list of ProjectInfoVMs. + private async Task> GetProjectInfosByIdsAsync(List projectIds) + { + // --- Step 1: Fetch from Cache --- + // The cache returns a list of MongoDB documents for the projects it found. + var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var finalViewModels = _mapper.Map>(cachedMongoDocs); + + _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); + + // --- Step 2: Identify Missing Projects --- + // If we found everything in the cache, we can return early. + if (finalViewModels.Count == projectIds.Count) + { + return finalViewModels; + } + + var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id + var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); + + // --- Step 3: Fetch Missing from Database --- + if (missingIds.Any()) + { + _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); + + var projectsFromDb = await _context.Projects + .Where(p => missingIds.Contains(p.Id)) + .AsNoTracking() // Use AsNoTracking for read-only query performance + .ToListAsync(); + + if (projectsFromDb.Any()) + { + // Map the newly fetched projects (from SQL) to their ViewModel + var vmsFromDb = _mapper.Map>(projectsFromDb); + finalViewModels.AddRange(vmsFromDb); + + // --- Step 4: Update Cache with Missing Items in a new scope --- + _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); + await _cache.AddProjectDetailsList(projectsFromDb); + } + } + + return finalViewModels; + } + + private async Task GetProjectViewModel(Guid? id, Project project) + { + ProjectDetailsVM vm = new ProjectDetailsVM(); + + // List buildings = _unitOfWork.Building.GetAll(c => c.ProjectId == id).ToList(); + List buildings = await _context.Buildings.Where(c => c.ProjectId == id).ToListAsync(); + List idList = buildings.Select(o => o.Id).ToList(); + // List floors = _unitOfWork.Floor.GetAll(c => idList.Contains(c.Id)).ToList(); + List floors = await _context.Floor.Where(c => idList.Contains(c.BuildingId)).ToListAsync(); + idList = floors.Select(o => o.Id).ToList(); + //List workAreas = _unitOfWork.WorkArea.GetAll(c => idList.Contains(c.Id), includeProperties: "WorkItems,WorkItems.ActivityMaster").ToList(); + + List workAreas = await _context.WorkAreas.Where(c => idList.Contains(c.FloorId)).ToListAsync(); + + idList = workAreas.Select(o => o.Id).ToList(); + List workItems = await _context.WorkItems.Include(c => c.WorkCategoryMaster).Where(c => idList.Contains(c.WorkAreaId)).Include(c => c.ActivityMaster).ToListAsync(); + // List workItems = _unitOfWork.WorkItem.GetAll(c => idList.Contains(c.WorkAreaId), includeProperties: "ActivityMaster").ToList(); + idList = workItems.Select(t => t.Id).ToList(); + List tasks = await _context.TaskAllocations.Where(t => idList.Contains(t.WorkItemId) && t.AssignmentDate.Date == DateTime.UtcNow.Date).ToListAsync(); + vm.project = project; + vm.buildings = buildings; + vm.floors = floors; + vm.workAreas = workAreas; + vm.workItems = workItems; + vm.Tasks = tasks; + return vm; + } + + /// + /// Fetches project details from the database for a given list of project IDs and assembles them into MongoDB models. + /// This method encapsulates the optimized, parallel database queries. + /// + /// The list of project IDs to fetch. + /// The current tenant ID for filtering. + /// A list of fully populated ProjectMongoDB objects. + private async Task> FetchAndBuildProjectDetails(List projectIdsToFetch, Guid tenantId) + { + // Task to get base project details for the MISSING projects + var projectsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Projects.AsNoTracking() + .Where(p => projectIdsToFetch.Contains(p.Id) && p.TenantId == tenantId) + .ToListAsync(); + }); + + // Task to get team sizes for the MISSING projects + var teamSizesTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.ProjectAllocations.AsNoTracking() + .Where(pa => pa.TenantId == tenantId && projectIdsToFetch.Contains(pa.ProjectId) && pa.IsActive) + .GroupBy(pa => pa.ProjectId) + .Select(g => new { ProjectId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ProjectId, x => x.Count); + }); + + // Task to get work summaries for the MISSING projects + var workSummariesTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.WorkItems.AsNoTracking() + .Where(wi => wi.TenantId == tenantId && + wi.WorkArea != null && + wi.WorkArea.Floor != null && + wi.WorkArea.Floor.Building != null && + projectIdsToFetch.Contains(wi.WorkArea.Floor.Building.ProjectId)) + .GroupBy(wi => wi.WorkArea!.Floor!.Building!.ProjectId) + .Select(g => new { ProjectId = g.Key, PlannedWork = g.Sum(i => i.PlannedWork), CompletedWork = g.Sum(i => i.CompletedWork) }) + .ToDictionaryAsync(x => x.ProjectId); + }); + + // Await all parallel tasks to complete + await Task.WhenAll(projectsTask, teamSizesTask, workSummariesTask); + + var projects = await projectsTask; + var teamSizes = await teamSizesTask; + var workSummaries = await workSummariesTask; + + // Proactively update the cache with the items we just fetched. + _logger.LogInfo("Updating cache with {NewItemCount} newly fetched projects.", projects.Count); + await _cache.AddProjectDetailsList(projects); + + // This section would build the full ProjectMongoDB objects, similar to your AddProjectDetailsList method. + // For brevity, assuming you have a mapper or a builder for this. Here's a simplified representation: + var mongoDetailsList = new List(); + foreach (var project in projects) + { + // This is a placeholder for the full build logic from your other methods. + // In a real scenario, you would fetch all hierarchy levels (buildings, floors, etc.) + // for the `projectIdsToFetch` and build the complete MongoDB object. + var mongoDetail = _mapper.Map(project); + mongoDetail.Id = project.Id; + mongoDetail.TeamSize = teamSizes.GetValueOrDefault(project.Id, 0); + if (workSummaries.TryGetValue(project.Id, out var summary)) + { + mongoDetail.PlannedWork = summary.PlannedWork; + mongoDetail.CompletedWork = summary.CompletedWork; + } + mongoDetailsList.Add(mongoDetail); + } + + return mongoDetailsList; + } + + /// + /// Private helper to encapsulate the cache-first data retrieval logic. + /// + /// A ProjectDetailVM if found, otherwise null. + private async Task GetProjectDataAsync(Guid projectId, Guid tenantId) + { + // --- Cache First --- + _logger.LogDebug("Attempting to fetch project {ProjectId} from cache.", projectId); + var cachedProject = await _cache.GetProjectDetails(projectId); + if (cachedProject != null) + { + _logger.LogInfo("Cache HIT for project {ProjectId}.", projectId); + // Map from the cache model (e.g., ProjectMongoDB) to the response ViewModel. + return _mapper.Map(cachedProject); + } + + // --- Database Second (on Cache Miss) --- + _logger.LogInfo("Cache MISS for project {ProjectId}. Fetching from database.", projectId); + var dbProject = await _context.Projects + .AsNoTracking() // Use AsNoTracking for read-only queries. + .Where(p => p.Id == projectId && p.TenantId == tenantId) + .SingleOrDefaultAsync(); + + if (dbProject == null) + { + return null; // The project doesn't exist. + } + + // --- Proactively Update Cache --- + // The next request for this project will now be a cache hit. + try + { + // Map the DB entity to the cache model (e.g., ProjectMongoDB) before caching. + await _cache.AddProjectDetails(dbProject); + _logger.LogInfo("Updated cache with project {ProjectId}.", projectId); + } + catch (Exception ex) + { + _logger.LogWarning("Failed to update cache for project {ProjectId} : \n {Error}", projectId, ex.Message); + } + + // Map from the database entity to the response ViewModel. + return dbProject; + } + + // Helper method for background cache update + private async Task UpdateCacheInBackground(Project project) + { + try + { + // This logic can be more complex, but the idea is to update or add. + if (!await _cache.UpdateProjectDetailsOnly(project)) + { + await _cache.AddProjectDetails(project); + } + _logger.LogInfo("Background cache update succeeded for project {ProjectId}.", project.Id); + } + catch (Exception ex) + { + _logger.LogError("Background cache update failed for project {ProjectId} \n {Error}", project.Id, ex.Message); + } + } + + #endregion + } +} diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs new file mode 100644 index 0000000..a23eba0 --- /dev/null +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -0,0 +1,17 @@ +using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Utilities; + +namespace Marco.Pms.Services.Service.ServiceInterfaces +{ + public interface IProjectServices + { + Task> GetAllProjectsBasicAsync(Guid tenantId, Employee loggedInEmployee); + Task> GetAllProjectsAsync(Guid tenantId, Employee loggedInEmployee); + Task> GetProjectAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectDetailsAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectDetailsOldAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + Task> CreateProjectAsync(CreateProjectDto projectDto, Guid tenantId, Employee loggedInEmployee); + Task> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee); + } +} -- 2.43.0 From 560d2f2d4dc2c7b8ee3a93ac4974a93132aa9f21 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 12:44:38 +0530 Subject: [PATCH 32/50] adde functionality to delete workItems from cache --- .../Controllers/AttendanceController.cs | 30 +-- .../Controllers/AuthController.cs | 34 +-- .../Controllers/DashboardController.cs | 10 +- .../Controllers/DirectoryController.cs | 4 +- .../Controllers/EmployeeController.cs | 4 +- .../Controllers/ForumController.cs | 30 +-- .../Controllers/MasterController.cs | 48 ++-- .../Controllers/ProjectController.cs | 129 ++++------ .../Controllers/ReportController.cs | 16 +- .../Helpers/CacheUpdateHelper.cs | 8 +- Marco.Pms.Services/Helpers/DirectoryHelper.cs | 18 +- Marco.Pms.Services/Helpers/EmployeeHelper.cs | 6 +- Marco.Pms.Services/Helpers/MasterHelper.cs | 10 +- Marco.Pms.Services/Helpers/ReportHelper.cs | 10 +- Marco.Pms.Services/Helpers/RolesHelper.cs | 4 +- ...ectMappingProfile.cs => MappingProfile.cs} | 12 +- Marco.Pms.Services/Program.cs | 1 + Marco.Pms.Services/Service/ILoggingService.cs | 2 +- Marco.Pms.Services/Service/LoggingServices.cs | 6 +- Marco.Pms.Services/Service/ProjectServices.cs | 227 +++++++++++++++++- .../Service/RefreshTokenService.cs | 14 +- Marco.Pms.Services/Service/S3UploadService.cs | 14 +- .../ServiceInterfaces/IProjectServices.cs | 2 + .../ServiceInterfaces/ISignalRService.cs | 7 + Marco.Pms.Services/Service/SignalRService.cs | 29 +++ 25 files changed, 444 insertions(+), 231 deletions(-) rename Marco.Pms.Services/MappingProfiles/{ProjectMappingProfile.cs => MappingProfile.cs} (75%) create mode 100644 Marco.Pms.Services/Service/ServiceInterfaces/ISignalRService.cs create mode 100644 Marco.Pms.Services/Service/SignalRService.cs diff --git a/Marco.Pms.Services/Controllers/AttendanceController.cs b/Marco.Pms.Services/Controllers/AttendanceController.cs index 4c2f2c1..1a5e4e7 100644 --- a/Marco.Pms.Services/Controllers/AttendanceController.cs +++ b/Marco.Pms.Services/Controllers/AttendanceController.cs @@ -90,18 +90,18 @@ namespace MarcoBMS.Services.Controllers if (dateFrom != null && DateTime.TryParse(dateFrom, out fromDate) == false) { - _logger.LogError("User sent Invalid from Date while featching attendance logs"); + _logger.LogWarning("User sent Invalid from Date while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (dateTo != null && DateTime.TryParse(dateTo, out toDate) == false) { - _logger.LogError("User sent Invalid to Date while featching attendance logs"); + _logger.LogWarning("User sent Invalid to Date while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (employeeId == Guid.Empty) { - _logger.LogError("The employee Id sent by user is empty"); + _logger.LogWarning("The employee Id sent by user is empty"); return BadRequest(ApiResponse.ErrorResponse("Employee ID is required and must not be Empty.", "Employee ID is required and must not be empty.", 400)); } List attendances = await _context.Attendes.Where(c => c.EmployeeID == employeeId && c.TenantId == TenantId && c.AttendanceDate.Date >= fromDate && c.AttendanceDate.Date <= toDate).ToListAsync(); @@ -161,18 +161,18 @@ namespace MarcoBMS.Services.Controllers if (dateFrom != null && DateTime.TryParse(dateFrom, out fromDate) == false) { - _logger.LogError("User sent Invalid fromDate while featching attendance logs"); + _logger.LogWarning("User sent Invalid fromDate while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (dateTo != null && DateTime.TryParse(dateTo, out toDate) == false) { - _logger.LogError("User sent Invalid toDate while featching attendance logs"); + _logger.LogWarning("User sent Invalid toDate while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (projectId == Guid.Empty) { - _logger.LogError("The project Id sent by user is less than or equal to zero"); + _logger.LogWarning("The project Id sent by user is less than or equal to zero"); return BadRequest(ApiResponse.ErrorResponse("Project ID is required and must be greater than zero.", "Project ID is required and must be greater than zero.", 400)); } @@ -276,13 +276,13 @@ namespace MarcoBMS.Services.Controllers if (date != null && DateTime.TryParse(date, out forDate) == false) { - _logger.LogError("User sent Invalid Date while featching attendance logs"); + _logger.LogWarning("User sent Invalid Date while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (projectId == Guid.Empty) { - _logger.LogError("The project Id sent by user is less than or equal to zero"); + _logger.LogWarning("The project Id sent by user is less than or equal to zero"); return BadRequest(ApiResponse.ErrorResponse("Project ID is required and must be greater than zero.", "Project ID is required and must be greater than zero.", 400)); } @@ -425,7 +425,7 @@ namespace MarcoBMS.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance \n {Error}", string.Join(",", errors)); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -439,14 +439,14 @@ namespace MarcoBMS.Services.Controllers if (recordAttendanceDot.MarkTime == null) { - _logger.LogError("User sent Invalid Mark Time while marking attendance"); + _logger.LogWarning("User sent Invalid Mark Time while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid Mark Time", "Invalid Mark Time", 400)); } DateTime finalDateTime = GetDateFromTimeStamp(recordAttendanceDot.Date, recordAttendanceDot.MarkTime); if (recordAttendanceDot.Comment == null) { - _logger.LogError("User sent Invalid comment while marking attendance"); + _logger.LogWarning("User sent Invalid comment while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid Comment", "Invalid Comment", 400)); } @@ -480,7 +480,7 @@ namespace MarcoBMS.Services.Controllers } else { - _logger.LogError("Employee {EmployeeId} sent regularization request but it check-out time is earlier than check-out"); + _logger.LogWarning("Employee {EmployeeId} sent regularization request but it check-out time is earlier than check-out"); return BadRequest(ApiResponse.ErrorResponse("Check-out time must be later than check-in time", "Check-out time must be later than check-in time", 400)); } // do nothing @@ -585,7 +585,7 @@ namespace MarcoBMS.Services.Controllers catch (Exception ex) { await transaction.RollbackAsync(); // Rollback on failure - _logger.LogError("{Error} while marking attendance", ex.Message); + _logger.LogError(ex, "An Error occured while marking attendance"); var response = new { message = ex.Message, @@ -604,7 +604,7 @@ namespace MarcoBMS.Services.Controllers if (!ModelState.IsValid) { var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); - _logger.LogError("Invalid attendance model received."); + _logger.LogWarning("Invalid attendance model received. \n {Error}", string.Join(",", errors)); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -780,7 +780,7 @@ namespace MarcoBMS.Services.Controllers catch (Exception ex) { await transaction.RollbackAsync(); - _logger.LogError("Error while recording attendance : {Error}", ex.Message); + _logger.LogError(ex, "Error while recording attendance"); return BadRequest(ApiResponse.ErrorResponse("Something went wrong", ex.Message, 500)); } } diff --git a/Marco.Pms.Services/Controllers/AuthController.cs b/Marco.Pms.Services/Controllers/AuthController.cs index 1b45eb7..429a38b 100644 --- a/Marco.Pms.Services/Controllers/AuthController.cs +++ b/Marco.Pms.Services/Controllers/AuthController.cs @@ -1,8 +1,4 @@ -using System.Net; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Authentication; using Marco.Pms.Model.Dtos.Authentication; using Marco.Pms.Model.Dtos.Util; @@ -15,6 +11,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; namespace MarcoBMS.Services.Controllers { @@ -110,7 +110,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Unexpected error during login : {Error}", ex.Message); + _logger.LogError(ex, "Unexpected error during login"); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error", ex.Message, 500)); } } @@ -270,7 +270,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Unexpected error occurred while verifying MPIN : {Error}", ex.Message); + _logger.LogError(ex, "Unexpected error occurred while verifying MPIN"); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error", ex.Message, 500)); } } @@ -307,7 +307,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Unexpected error during logout : {Error}", ex.Message); + _logger.LogError(ex, "Unexpected error during logout"); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error occurred", ex.Message, 500)); } } @@ -351,7 +351,7 @@ namespace MarcoBMS.Services.Controllers if (string.IsNullOrWhiteSpace(user.UserName)) { - _logger.LogError("Username missing for user ID: {UserId}", user.Id); + _logger.LogWarning("Username missing for user ID: {UserId}", user.Id); return NotFound(ApiResponse.ErrorResponse("Username not found.", "Username not found.", 404)); } @@ -370,7 +370,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred during token refresh. : {Error}", ex.Message); + _logger.LogError(ex, "An unexpected error occurred during token refresh."); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error occurred.", ex.Message, 500)); } } @@ -406,7 +406,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Error while sending password reset email to: {Error}", ex.Message); + _logger.LogError(ex, "Error while sending password reset email to"); return StatusCode(500, ApiResponse.ErrorResponse("Error sending password reset email.", ex.Message, 500)); } } @@ -480,7 +480,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Error while sending reset password success email to user: {Error}", ex.Message); + _logger.LogError(ex, "Error while sending reset password success email to user"); // Continue, do not fail because of email issue } @@ -547,7 +547,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred while sending OTP to {Email} : {Error}", generateOTP.Email ?? "", ex.Message); + _logger.LogError(ex, "An unexpected error occurred while sending OTP to {Email}", generateOTP.Email ?? ""); return StatusCode(500, ApiResponse.ErrorResponse("An unexpected error occurred.", ex.Message, 500)); } } @@ -638,7 +638,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred during OTP login for email {Email} : {Error}", verifyOTP.Email ?? string.Empty, ex.Message); + _logger.LogError(ex, "An unexpected error occurred during OTP login for email {Email}", verifyOTP.Email ?? string.Empty); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error", ex.Message, 500)); } } @@ -719,7 +719,7 @@ namespace MarcoBMS.Services.Controllers if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToList(); - _logger.LogError("Password reset failed for user {Email}. Errors: {Errors}", changePassword.Email, string.Join("; ", errors)); + _logger.LogWarning("Password reset failed for user {Email}. Errors: {Errors}", changePassword.Email, string.Join("; ", errors)); return BadRequest(ApiResponse.ErrorResponse("Failed to change password", errors, 400)); } @@ -732,7 +732,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception exp) { - _logger.LogError("An unexpected error occurred while changing password : {Error}", exp.Message); + _logger.LogError(exp, "An unexpected error occurred while changing password"); return StatusCode(500, ApiResponse.ErrorResponse("An unexpected error occurred.", exp.Message, 500)); } } @@ -752,7 +752,7 @@ namespace MarcoBMS.Services.Controllers // Validate employee and MPIN input if (requestEmployee == null || string.IsNullOrWhiteSpace(generateMPINDto.MPIN) || generateMPINDto.MPIN.Length != 6 || !generateMPINDto.MPIN.All(char.IsDigit)) { - _logger.LogError("Employee {EmployeeId} provided invalid information to generate MPIN", loggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} provided invalid information to generate MPIN", loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse("Provided invalid information", "Provided invalid information", 400)); } diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index f2332df..0e01717 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -364,7 +364,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Number of pending regularization and pending check-out are fetched successfully for employee {EmployeeId}", LoggedInEmployee.Id); return Ok(ApiResponse.SuccessResponse(response, "Pending regularization and pending check-out are fetched successfully", 200)); } - _logger.LogError("No attendance entry was found for employee {EmployeeId}", LoggedInEmployee.Id); + _logger.LogWarning("No attendance entry was found for employee {EmployeeId}", LoggedInEmployee.Id); return NotFound(ApiResponse.ErrorResponse("No attendance entry was found for this employee", "No attendance entry was found for this employee", 404)); } @@ -378,14 +378,14 @@ namespace Marco.Pms.Services.Controllers List? projectProgressionVMs = new List(); if (date != null && DateTime.TryParse(date, out currentDate) == false) { - _logger.LogError($"user send invalid date"); + _logger.LogWarning($"user send invalid date"); return BadRequest(ApiResponse.ErrorResponse("Invalid date.", "Invalid date.", 400)); } Project? project = await _context.Projects.FirstOrDefaultAsync(p => p.Id == projectId); if (project == null) { - _logger.LogError("Employee {EmployeeId} was attempted to get project attendance for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); + _logger.LogWarning("Employee {EmployeeId} was attempted to get project attendance for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); } List? projectAllocation = await _context.ProjectAllocations.Where(p => p.ProjectId == projectId && p.IsActive && p.TenantId == tenantId).ToListAsync(); @@ -431,14 +431,14 @@ namespace Marco.Pms.Services.Controllers DateTime currentDate = DateTime.UtcNow; if (date != null && DateTime.TryParse(date, out currentDate) == false) { - _logger.LogError($"user send invalid date"); + _logger.LogWarning($"user send invalid date"); return BadRequest(ApiResponse.ErrorResponse("Invalid date.", "Invalid date.", 400)); } Project? project = await _context.Projects.FirstOrDefaultAsync(p => p.Id == projectId); if (project == null) { - _logger.LogError("Employee {EmployeeId} was attempted to get activities performed for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); + _logger.LogWarning("Employee {EmployeeId} was attempted to get activities performed for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); } diff --git a/Marco.Pms.Services/Controllers/DirectoryController.cs b/Marco.Pms.Services/Controllers/DirectoryController.cs index 4a0e41e..9eb06e0 100644 --- a/Marco.Pms.Services/Controllers/DirectoryController.cs +++ b/Marco.Pms.Services/Controllers/DirectoryController.cs @@ -77,7 +77,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _directoryHelper.CreateContact(createContact); @@ -256,7 +256,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _directoryHelper.CreateBucket(bucketDto); diff --git a/Marco.Pms.Services/Controllers/EmployeeController.cs b/Marco.Pms.Services/Controllers/EmployeeController.cs index 2f0ca5e..c9e19fa 100644 --- a/Marco.Pms.Services/Controllers/EmployeeController.cs +++ b/Marco.Pms.Services/Controllers/EmployeeController.cs @@ -382,7 +382,7 @@ namespace MarcoBMS.Services.Controllers Employee? existingEmployee = await _context.Employees.FirstOrDefaultAsync(e => e.Id == model.Id.Value); if (existingEmployee == null) { - _logger.LogError("User tries to update employee {EmployeeId} but not found in database", model.Id); + _logger.LogWarning("User tries to update employee {EmployeeId} but not found in database", model.Id); return NotFound(ApiResponse.ErrorResponse("Employee not found", "Employee not found", 404)); } byte[]? imageBytes = null; @@ -495,7 +495,7 @@ namespace MarcoBMS.Services.Controllers } else { - _logger.LogError("Employee with ID {EmploueeId} not found in database", id); + _logger.LogWarning("Employee with ID {EmploueeId} not found in database", id); } return Ok(ApiResponse.SuccessResponse(new { }, "Employee Suspended successfully", 200)); } diff --git a/Marco.Pms.Services/Controllers/ForumController.cs b/Marco.Pms.Services/Controllers/ForumController.cs index 769c08a..fb6d0e7 100644 --- a/Marco.Pms.Services/Controllers/ForumController.cs +++ b/Marco.Pms.Services/Controllers/ForumController.cs @@ -44,7 +44,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } Guid tenantId = _userHelper.GetTenantId(); @@ -66,7 +66,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -160,7 +160,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } Guid tenantId = _userHelper.GetTenantId(); @@ -197,7 +197,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -336,7 +336,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket {TicketId} updated", updateTicketDto.Id); return Ok(ApiResponse.SuccessResponse(ticketVM, "Ticket Updated Successfully", 200)); } - _logger.LogError("Ticket {TicketId} not Found in database", updateTicketDto.Id); + _logger.LogWarning("Ticket {TicketId} not Found in database", updateTicketDto.Id); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -349,7 +349,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -364,7 +364,7 @@ namespace Marco.Pms.Services.Controllers if (ticket == null) { - _logger.LogError("Ticket {TicketId} not Found in database", addCommentDto.TicketId); + _logger.LogWarning("Ticket {TicketId} not Found in database", addCommentDto.TicketId); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -379,7 +379,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -437,7 +437,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -451,7 +451,7 @@ namespace Marco.Pms.Services.Controllers if (ticket == null) { - _logger.LogError("Ticket {TicketId} not Found in database", updateCommentDto.TicketId); + _logger.LogWarning("Ticket {TicketId} not Found in database", updateCommentDto.TicketId); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -474,7 +474,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -552,7 +552,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -568,7 +568,7 @@ namespace Marco.Pms.Services.Controllers if (tickets == null || tickets.Count > 0) { - _logger.LogError("Tickets not Found in database"); + _logger.LogWarning("Tickets not Found in database"); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -578,12 +578,12 @@ namespace Marco.Pms.Services.Controllers { if (string.IsNullOrEmpty(forumAttachmentDto.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } if (forumAttachmentDto.TicketId == null) { - _logger.LogError("ticket ID is missing"); + _logger.LogWarning("ticket ID is missing"); return BadRequest(ApiResponse.ErrorResponse("ticket ID is missing", "ticket ID is missing", 400)); } var ticket = tickets.FirstOrDefault(t => t.Id == forumAttachmentDto.TicketId); diff --git a/Marco.Pms.Services/Controllers/MasterController.cs b/Marco.Pms.Services/Controllers/MasterController.cs index ebd8998..9000cdf 100644 --- a/Marco.Pms.Services/Controllers/MasterController.cs +++ b/Marco.Pms.Services/Controllers/MasterController.cs @@ -168,7 +168,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("activity updated successfully from tenant {tenantId}", tenantId); return Ok(ApiResponse.SuccessResponse(activityVM, "activity updated successfully", 200)); } - _logger.LogError("Activity {ActivityId} not found", id); + _logger.LogWarning("Activity {ActivityId} not found", id); return NotFound(ApiResponse.ErrorResponse("Activity not found", "Activity not found", 404)); } @@ -230,7 +230,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Status master {TicketStatusId} added successfully from tenant {tenantId}", statusMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(statusVM, "Ticket Status master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("Sent Empty payload", "Sent Empty payload", 400)); } @@ -251,10 +251,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Status master {TicketStatusId} updated successfully from tenant {tenantId}", statusMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(statusVM, "Ticket Status master updated successfully", 200)); } - _logger.LogError("Ticket Status master {TicketStatusId} not found in database", statusMasterDto.Id != null ? statusMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket Status master {TicketStatusId} not found in database", statusMasterDto.Id != null ? statusMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket Status master not found", "Ticket Status master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("Sent Empty payload", "Sent Empty payload", 400)); } @@ -281,7 +281,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Status {TickeStatusId} not found in database", id); + _logger.LogWarning("Ticket Status {TickeStatusId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket Status not found", "Ticket Status not found", 404)); } } @@ -318,7 +318,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket type master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -339,10 +339,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Type master {TicketTypeId} updated successfully from tenant {tenantId}", typeMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket type master updated successfully", 200)); } - _logger.LogError("Ticket type master {TicketTypeId} not found in database", typeMasterDto.Id != null ? typeMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket type master {TicketTypeId} not found in database", typeMasterDto.Id != null ? typeMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket type master not found", "Ticket type master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -369,7 +369,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Type {TickeTypeId} not found in database", id); + _logger.LogWarning("Ticket Type {TickeTypeId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket Type not found", "Ticket Type not found", 404)); } } @@ -407,7 +407,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket Priority master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } [HttpPost("ticket-priorities/edit/{id}")] @@ -427,10 +427,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Priority master {TicketPriorityId} updated successfully from tenant {tenantId}", typeMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket Priority master updated successfully", 200)); } - _logger.LogError("Ticket Priority master {TicketPriorityId} not found in database", priorityMasterDto.Id != null ? priorityMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket Priority master {TicketPriorityId} not found in database", priorityMasterDto.Id != null ? priorityMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket Priority master not found", "Ticket Priority master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -457,7 +457,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Priority {TickePriorityId} not found in database", id); + _logger.LogWarning("Ticket Priority {TickePriorityId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket Priority not found", "Ticket Priority not found", 404)); } } @@ -494,7 +494,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket tag master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -515,10 +515,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Tag master {TicketTypeId} updated successfully from tenant {tenantId}", tagMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket tag master updated successfully", 200)); } - _logger.LogError("Ticket tag master {TicketTypeId} not found in database", tagMasterDto.Id != null ? tagMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket tag master {TicketTypeId} not found in database", tagMasterDto.Id != null ? tagMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket tag master not found", "Ticket tag master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -545,7 +545,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Tag {TickeTagId} not found in database", id); + _logger.LogWarning("Ticket Tag {TickeTagId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket tag not found", "Ticket tag not found", 404)); } } @@ -609,7 +609,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(workCategoryMasterVM, "Work category master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -624,7 +624,7 @@ namespace Marco.Pms.Services.Controllers { if (workCategory.IsSystem) { - _logger.LogError("User tries to update system-defined work category"); + _logger.LogWarning("User tries to update system-defined work category"); return BadRequest(ApiResponse.ErrorResponse("Cannot update system-defined work", "Cannot update system-defined work", 400)); } workCategory = workCategoryMasterDto.ToWorkCategoryMasterFromWorkCategoryMasterDto(tenantId); @@ -635,10 +635,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Work category master {WorkCategoryId} updated successfully from tenant {tenantId}", workCategory.Id, tenantId); return Ok(ApiResponse.SuccessResponse(workCategoryMasterVM, "Work category master updated successfully", 200)); } - _logger.LogError("Work category master {WorkCategoryId} not found in database", workCategoryMasterDto.Id ?? Guid.Empty); + _logger.LogWarning("Work category master {WorkCategoryId} not found in database", workCategoryMasterDto.Id ?? Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Work category master not found", "Work category master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -666,7 +666,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Work category {WorkCategoryId} not found in database", id); + _logger.LogWarning("Work category {WorkCategoryId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Work category not found", "Work category not found", 404)); } } @@ -689,7 +689,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _masterHelper.CreateWorkStatus(createWorkStatusDto); @@ -803,7 +803,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _masterHelper.CreateContactTag(contactTagDto); diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index e7d257f..236e0cb 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -6,19 +6,18 @@ using Marco.Pms.Model.Mapper; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; -using Marco.Pms.Model.ViewModels.Employee; using Marco.Pms.Model.ViewModels.Projects; 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 MongoDB.Driver; +using Project = Marco.Pms.Model.Projects.Project; namespace MarcoBMS.Services.Controllers { @@ -31,14 +30,20 @@ namespace MarcoBMS.Services.Controllers private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly ILoggingService _logger; - private readonly IHubContext _signalR; + private readonly ISignalRService _signalR; private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; private readonly Guid tenantId; - public ProjectController(ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, - IHubContext signalR, CacheUpdateHelper cache, PermissionServices permission, IProjectServices projectServices) + public ProjectController( + ApplicationDbContext context, + UserHelper userHelper, + ILoggingService logger, + ISignalRService signalR, + CacheUpdateHelper cache, + PermissionServices permission, + IProjectServices projectServices) { _context = context; _userHelper = userHelper; @@ -174,7 +179,7 @@ namespace MarcoBMS.Services.Controllers if (response.Success) { var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Create_Project", Response = response.Data }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); } return StatusCode(response.StatusCode, response); } @@ -204,7 +209,7 @@ namespace MarcoBMS.Services.Controllers if (response.Success) { var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Update_Project", Response = response.Data }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); } return StatusCode(response.StatusCode, response); } @@ -213,90 +218,38 @@ namespace MarcoBMS.Services.Controllers #region =================================================================== Project Allocation APIs =================================================================== - [HttpGet] - [Route("employees/get/{projectid?}/{includeInactive?}")] - public async Task GetEmployeeByProjectID(Guid? projectid, bool includeInactive = false) + [HttpGet("employees/get/{projectid?}/{includeInactive?}")] + public async Task GetEmployeeByProjectId(Guid? projectId, bool includeInactive = false) { + // --- Step 1: Input Validation --- if (!ModelState.IsValid) { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get employee list by ProjectId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - if (projectid != null) - { - // Fetch assigned project - List result = new List(); - - if ((bool)includeInactive) - { - - result = await (from rpm in _context.Employees.Include(c => c.JobRole) - join fp in _context.ProjectAllocations.Where(c => c.TenantId == tenantId && c.ProjectId == projectid) - on rpm.Id equals fp.EmployeeId - select rpm).ToListAsync(); - } - else - { - result = await (from rpm in _context.Employees.Include(c => c.JobRole) - join fp in _context.ProjectAllocations.Where(c => c.TenantId == tenantId && c.ProjectId == projectid && c.IsActive) - on rpm.Id equals fp.EmployeeId - select rpm).ToListAsync(); - } - - List resultVM = new List(); - foreach (Employee employee in result) - { - EmployeeVM vm = employee.ToEmployeeVMFromEmployee(); - resultVM.Add(vm); - } - - return Ok(ApiResponse.SuccessResponse(resultVM, "Success.", 200)); - } - else - { - return NotFound(ApiResponse.ErrorResponse("Invalid Input Parameter", 404)); - } - - + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetEmployeeByProjectIdAsync(projectId, includeInactive, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } - [HttpGet] - [Route("allocation/{projectId}")] + [HttpGet("allocation/{projectId}")] public async Task GetProjectAllocation(Guid? projectId) { + // --- Step 1: Input Validation --- if (!ModelState.IsValid) { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get employee list by ProjectId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - var employees = await _context.ProjectAllocations - .Where(c => c.TenantId == tenantId && c.ProjectId == projectId && c.Employee != null) - .Include(e => e.Employee) - .Select(e => new - { - ID = e.Id, - EmployeeId = e.EmployeeId, - ProjectId = e.ProjectId, - AllocationDate = e.AllocationDate, - ReAllocationDate = e.ReAllocationDate, - FirstName = e.Employee != null ? e.Employee.FirstName : string.Empty, - LastName = e.Employee != null ? e.Employee.LastName : string.Empty, - MiddleName = e.Employee != null ? e.Employee.MiddleName : string.Empty, - IsActive = e.IsActive, - JobRoleId = (e.JobRoleId != null ? e.JobRoleId : e.Employee != null ? e.Employee.JobRoleId : null) - }).ToListAsync(); - - return Ok(ApiResponse.SuccessResponse(employees, "Success.", 200)); + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectAllocationAsync(projectId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpPost("allocation")] @@ -375,7 +328,7 @@ namespace MarcoBMS.Services.Controllers } var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); } @@ -494,7 +447,7 @@ namespace MarcoBMS.Services.Controllers await _cache.ClearAllProjectIds(employeeId); var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); } @@ -799,7 +752,7 @@ namespace MarcoBMS.Services.Controllers var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = message }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); return Ok(ApiResponse.SuccessResponse(responseList, responseMessage, 200)); } @@ -826,9 +779,15 @@ namespace MarcoBMS.Services.Controllers workAreaIds.Add(task.WorkAreaId); + var projectId = floor?.Building?.ProjectId; var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = $"Task Deleted in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}, in Area: {task.WorkArea?.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}" }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); + await _cache.DeleteWorkItemByIdAsync(task.Id); + if (projectId != null) + { + await _cache.DeleteProjectByIdAsync(projectId.Value); + } } else { @@ -847,7 +806,7 @@ namespace MarcoBMS.Services.Controllers } else { - _logger.LogError("Task with ID {WorkItemId} not found ID database", id); + _logger.LogWarning("Task with ID {WorkItemId} not found ID database", id); } return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); } @@ -973,7 +932,7 @@ namespace MarcoBMS.Services.Controllers message = $"{message} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + await _signalR.SendNotificationAsync(notification); return Ok(ApiResponse.SuccessResponse(responseData, responseMessage, 200)); } return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400)); diff --git a/Marco.Pms.Services/Controllers/ReportController.cs b/Marco.Pms.Services/Controllers/ReportController.cs index 717a273..87382d7 100644 --- a/Marco.Pms.Services/Controllers/ReportController.cs +++ b/Marco.Pms.Services/Controllers/ReportController.cs @@ -106,7 +106,7 @@ namespace Marco.Pms.Services.Controllers } catch (Exception ex) { - _logger.LogError("Database Error: Failed to check existence of MailListId '{MailListId}' for TenantId: {TenantId}. : {Error}", mailDetailsDto.MailListId, tenantId, ex.Message); + _logger.LogError(ex, "Database Error: Failed to check existence of MailListId '{MailListId}' for TenantId: {TenantId}.", mailDetailsDto.MailListId, tenantId); return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while validating mail template.", 500)); } @@ -143,13 +143,13 @@ namespace Marco.Pms.Services.Controllers } catch (DbUpdateException dbEx) { - _logger.LogError("Database Error: Failed to save new mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}. : {Error}", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId, dbEx.Message); + _logger.LogError(dbEx, "Database Error: Failed to save new mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}.", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId); // Check for specific constraint violations if applicable (e.g., duplicate recipient for a project) return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while saving the mail details.", 500)); } catch (Exception ex) { - _logger.LogError("Unexpected Error: An unhandled exception occurred while adding mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}. : {Error}", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId, ex.Message); + _logger.LogError(ex, "Unexpected Error: An unhandled exception occurred while adding mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}.", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId); return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500)); } } @@ -234,7 +234,7 @@ namespace Marco.Pms.Services.Controllers } catch (Exception ex) { - _logger.LogError("Database Error: Failed to check for existing mail template with title '{Title}' for TenantId: {TenantId}.: {Error}", mailTemplateDto.Title, tenantId, ex.Message); + _logger.LogError(ex, "Database Error: Failed to check for existing mail template with title '{Title}' for TenantId: {TenantId}.", mailTemplateDto.Title, tenantId); return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while checking for existing templates.", 500)); } @@ -270,12 +270,12 @@ namespace Marco.Pms.Services.Controllers } catch (DbUpdateException dbEx) { - _logger.LogError("Database Error: Failed to save new mail template '{Title}' for TenantId: {TenantId}. : {Error}", mailTemplateDto.Title, tenantId, dbEx.Message); + _logger.LogError(dbEx, "Database Error: Failed to save new mail template '{Title}' for TenantId: {TenantId}. : {Error}", mailTemplateDto.Title, tenantId); return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while saving the mail template.", 500)); } catch (Exception ex) { - _logger.LogError("Unexpected Error: An unhandled exception occurred while adding mail template '{Title}' for TenantId: {TenantId}. : {Error}", mailTemplateDto.Title, tenantId, ex.Message); + _logger.LogError(ex, "Unexpected Error: An unhandled exception occurred while adding mail template '{Title}' for TenantId: {TenantId}.", mailTemplateDto.Title, tenantId); return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500)); } } @@ -350,7 +350,7 @@ namespace Marco.Pms.Services.Controllers { // 3. OPTIMIZATION: Make the process resilient. // If one task fails unexpectedly, log it and continue with others. - _logger.LogError("Failed to send report for project {ProjectId} : {Error}", mailGroup.ProjectId, ex.Message); + _logger.LogError(ex, "Failed to send report for project {ProjectId}", mailGroup.ProjectId); Interlocked.Increment(ref failureCount); } } @@ -527,7 +527,7 @@ namespace Marco.Pms.Services.Controllers catch (Exception ex) { // It's good practice to log any unexpected errors within a concurrent task. - _logger.LogError("Failed to process project report for ProjectId {ProjectId} : {Error}", group.ProjectId, ex.Message); + _logger.LogError(ex, "Failed to process project report for ProjectId {ProjectId}", group.ProjectId); } } }); diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 5bae90f..aca439b 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -118,8 +118,8 @@ namespace Marco.Pms.Services.Helpers projectDetails.ProjectStatus = new StatusMasterMongoDB { - Id = status?.Id.ToString(), - Status = status?.Status + Id = status!.Id.ToString(), + Status = status.Status }; // Use fast in-memory lookups instead of .Where() in loops. @@ -797,7 +797,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occured while fetching project report mail bodys: {Error}", ex.Message); + _logger.LogError(ex, "Error occured while fetching project report mail bodys"); return null; } } @@ -809,7 +809,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occured while adding project report mail bodys: {Error}", ex.Message); + _logger.LogError(ex, "Error occured while adding project report mail bodys"); } } } diff --git a/Marco.Pms.Services/Helpers/DirectoryHelper.cs b/Marco.Pms.Services/Helpers/DirectoryHelper.cs index 199a410..ad9001c 100644 --- a/Marco.Pms.Services/Helpers/DirectoryHelper.cs +++ b/Marco.Pms.Services/Helpers/DirectoryHelper.cs @@ -52,7 +52,7 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to access a contacts, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to access a contacts, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -202,7 +202,7 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to access a contacts with in bucket {BucketId}, but do not have permission", LoggedInEmployee.Id, id); + _logger.LogWarning("Employee {EmployeeId} attemped to access a contacts with in bucket {BucketId}, but do not have permission", LoggedInEmployee.Id, id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -490,7 +490,7 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to update a contact, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to update a contact, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -1169,7 +1169,7 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to access a buckets list, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to access a buckets list, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -1204,7 +1204,7 @@ namespace Marco.Pms.Services.Helpers var demo = !permissionIds.Contains(PermissionsMaster.DirectoryUser); if (!permissionIds.Contains(PermissionsMaster.DirectoryAdmin) && !permissionIds.Contains(PermissionsMaster.DirectoryAdmin) && !permissionIds.Contains(PermissionsMaster.DirectoryUser)) { - _logger.LogError("Employee {EmployeeId} attemped to create a bucket, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to create a bucket, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -1276,7 +1276,7 @@ namespace Marco.Pms.Services.Helpers } if (accessableBucket == null) { - _logger.LogError("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); + _logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 401); } @@ -1342,7 +1342,7 @@ namespace Marco.Pms.Services.Helpers } if (accessableBucket == null) { - _logger.LogError("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); + _logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 401); } var employeeIds = await _context.Employees.Where(e => e.TenantId == tenantId && e.IsActive).Select(e => e.Id).ToListAsync(); @@ -1396,7 +1396,7 @@ namespace Marco.Pms.Services.Helpers } if (removededEmployee > 0) { - _logger.LogError("Employee {EmployeeId} removed {conut} number of employees from bucket {BucketId}", LoggedInEmployee.Id, removededEmployee, bucketId); + _logger.LogWarning("Employee {EmployeeId} removed {conut} number of employees from bucket {BucketId}", LoggedInEmployee.Id, removededEmployee, bucketId); } return ApiResponse.SuccessResponse(bucketVM, "Details updated successfully", 200); } @@ -1443,7 +1443,7 @@ namespace Marco.Pms.Services.Helpers } if (accessableBucket == null) { - _logger.LogError("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); + _logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 401); } diff --git a/Marco.Pms.Services/Helpers/EmployeeHelper.cs b/Marco.Pms.Services/Helpers/EmployeeHelper.cs index 343144a..09dcbe2 100644 --- a/Marco.Pms.Services/Helpers/EmployeeHelper.cs +++ b/Marco.Pms.Services/Helpers/EmployeeHelper.cs @@ -33,7 +33,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occured while fetching employee by application user ID {ApplicationUserId}", ApplicationUserID); return new Employee(); } } @@ -66,7 +66,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occoured while filtering employees by string {SearchString} or project {ProjectId}", searchString, ProjectId ?? Guid.Empty); return new List(); } } @@ -102,7 +102,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occured while featching list of employee by project ID {ProjectId}", ProjectId ?? Guid.Empty); return new List(); } } diff --git a/Marco.Pms.Services/Helpers/MasterHelper.cs b/Marco.Pms.Services/Helpers/MasterHelper.cs index f994639..83bc007 100644 --- a/Marco.Pms.Services/Helpers/MasterHelper.cs +++ b/Marco.Pms.Services/Helpers/MasterHelper.cs @@ -218,7 +218,7 @@ namespace Marco.Pms.Services.Helpers _logger.LogInfo("Contact tag master {ConatctTagId} updated successfully by employee {EmployeeId}", contactTagVm.Id, LoggedInEmployee.Id); return ApiResponse.SuccessResponse(contactTagVm, "Contact Tag master updated successfully", 200); } - _logger.LogError("Contact Tag master {ContactTagId} not found in database", id); + _logger.LogWarning("Contact Tag master {ContactTagId} not found in database", id); return ApiResponse.ErrorResponse("Contact Tag master not found", "Contact tag master not found", 404); } _logger.LogWarning("Employee with ID {LoggedInEmployeeId} sended empty payload", LoggedInEmployee.Id); @@ -294,7 +294,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while fetching work status list : {Error}", ex.Message); + _logger.LogWarning("Error occurred while fetching work status list : {Error}", ex.Message); return ApiResponse.ErrorResponse("An error occurred", "Unable to fetch work status list", 500); } } @@ -343,7 +343,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while creating work status : {Error}", ex.Message); + _logger.LogWarning("Error occurred while creating work status : {Error}", ex.Message); return ApiResponse.ErrorResponse("An error occurred", "Unable to create work status", 500); } } @@ -403,7 +403,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while updating work status ID: {Id} : {Error}", id, ex.Message); + _logger.LogError(ex, "Error occurred while updating work status ID: {Id}", id); return ApiResponse.ErrorResponse("An error occurred", "Unable to update the work status at this time", 500); } } @@ -458,7 +458,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while deleting WorkStatus Id: {Id} : {Error}", id, ex.Message); + _logger.LogError(ex, "Error occurred while deleting WorkStatus Id: {Id}", id); return ApiResponse.ErrorResponse("An error occurred", "Unable to delete work status", 500); } } diff --git a/Marco.Pms.Services/Helpers/ReportHelper.cs b/Marco.Pms.Services/Helpers/ReportHelper.cs index 4ec0978..4ec9453 100644 --- a/Marco.Pms.Services/Helpers/ReportHelper.cs +++ b/Marco.Pms.Services/Helpers/ReportHelper.cs @@ -289,13 +289,13 @@ namespace Marco.Pms.Services.Helpers // --- Input Validation --- if (projectId == Guid.Empty) { - _logger.LogError("Validation Error: Provided empty project ID while fetching project report."); + _logger.LogWarning("Validation Error: Provided empty project ID while fetching project report."); return ApiResponse.ErrorResponse("Provided empty Project ID.", "Provided empty Project ID.", 400); } if (recipientEmails == null || !recipientEmails.Any()) { - _logger.LogError("Validation Error: No recipient emails provided for project ID {ProjectId}.", projectId); + _logger.LogWarning("Validation Error: No recipient emails provided for project ID {ProjectId}.", projectId); return ApiResponse.ErrorResponse("No recipient emails provided.", "No recipient emails provided.", 400); } @@ -316,7 +316,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Email Sending Error: Failed to send project statistics email for project ID {ProjectId}. : {Error}", projectId, ex.Message); + _logger.LogError(ex, "Email Sending Error: Failed to send project statistics email for project ID {ProjectId}.", projectId); return ApiResponse.ErrorResponse("Failed to send email.", "An error occurred while sending the email.", 500); } @@ -350,14 +350,14 @@ namespace Marco.Pms.Services.Helpers } catch (DbUpdateException dbEx) { - _logger.LogError("Database Error: Failed to save mail logs for project ID {ProjectId}. : {Error}", projectId, dbEx.Message); + _logger.LogError(dbEx, "Database Error: Failed to save mail logs for project ID {ProjectId}.", projectId); // Depending on your requirements, you might still return success here as the email was sent. // Or return an error indicating the logging failed. return ApiResponse.ErrorResponse("Email sent, but failed to log activity.", "Email sent, but an error occurred while logging.", 500); } catch (Exception ex) { - _logger.LogError("Unexpected Error: An unhandled exception occurred while processing project statistics for project ID {ProjectId}. : {Error}", projectId, ex.Message); + _logger.LogError(ex, "Unexpected Error: An unhandled exception occurred while processing project statistics for project ID {ProjectId}.", projectId); return ApiResponse.ErrorResponse("An unexpected error occurred.", "An unexpected error occurred.", 500); } } diff --git a/Marco.Pms.Services/Helpers/RolesHelper.cs b/Marco.Pms.Services/Helpers/RolesHelper.cs index cd73c0f..ef9f824 100644 --- a/Marco.Pms.Services/Helpers/RolesHelper.cs +++ b/Marco.Pms.Services/Helpers/RolesHelper.cs @@ -84,7 +84,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("An error occurred while fetching permissions for EmployeeId {EmployeeId} : {Error}", EmployeeId, ex.Message); + _logger.LogError(ex, "An error occurred while fetching permissions for EmployeeId {EmployeeId}", EmployeeId); return new List(); } } @@ -144,7 +144,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("An error occurred while fetching permissions for RoleId {RoleId}: {Error}", roleId, ex.Message); + _logger.LogError(ex, "An error occurred while fetching permissions for RoleId {RoleId}", roleId); // Return an empty list as a safe default to prevent downstream failures. return new List(); } diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs similarity index 75% rename from Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs rename to Marco.Pms.Services/MappingProfiles/MappingProfile.cs index b811056..7d627bc 100644 --- a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -1,16 +1,19 @@ using AutoMapper; using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Employees; using Marco.Pms.Model.Master; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; +using Marco.Pms.Model.ViewModels.Employee; using Marco.Pms.Model.ViewModels.Projects; namespace Marco.Pms.Services.MappingProfiles { - public class ProjectMappingProfile : Profile + public class MappingProfile : Profile { - public ProjectMappingProfile() + public MappingProfile() { + #region ======================================================= Projects ======================================================= // Your mappings CreateMap(); CreateMap(); @@ -40,6 +43,11 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); CreateMap(); + #endregion + + #region ======================================================= Projects ======================================================= + CreateMap(); + #endregion } } } diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 6553745..26d8eba 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -158,6 +158,7 @@ builder.Services.AddTransient(); #region Customs Services builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion diff --git a/Marco.Pms.Services/Service/ILoggingService.cs b/Marco.Pms.Services/Service/ILoggingService.cs index b835d0c..6d795cd 100644 --- a/Marco.Pms.Services/Service/ILoggingService.cs +++ b/Marco.Pms.Services/Service/ILoggingService.cs @@ -5,7 +5,7 @@ void LogInfo(string? message, params object[]? args); void LogDebug(string? message, params object[]? args); void LogWarning(string? message, params object[]? args); - void LogError(string? message, params object[]? args); + void LogError(Exception? ex, string? message, params object[]? args); } } diff --git a/Marco.Pms.Services/Service/LoggingServices.cs b/Marco.Pms.Services/Service/LoggingServices.cs index 5a016de..751f22c 100644 --- a/Marco.Pms.Services/Service/LoggingServices.cs +++ b/Marco.Pms.Services/Service/LoggingServices.cs @@ -11,16 +11,16 @@ namespace MarcoBMS.Services.Service _logger = logger; } - public void LogError(string? message, params object[]? args) + public void LogError(Exception? ex, string? message, params object[]? args) { using (LogContext.PushProperty("LogLevel", "Error")) if (args != null) { - _logger.LogError(message, args); + _logger.LogError(ex, message, args); } else { - _logger.LogError(message); + _logger.LogError(ex, message); } } diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 3280558..dcaf20e 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -1,4 +1,5 @@ using AutoMapper; +using AutoMapper.QueryableExtensions; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Activities; using Marco.Pms.Model.Dtos.Project; @@ -7,12 +8,15 @@ using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; +using Marco.Pms.Model.ViewModels.Employee; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; +using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; +using Project = Marco.Pms.Model.Projects.Project; namespace Marco.Pms.Services.Service { @@ -75,7 +79,7 @@ namespace Marco.Pms.Services.Service catch (Exception ex) { // --- Step 5: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in GetAllProjectsBasic for tenant {TenantId}. \n {Error}", tenantId, ex.Message); + _logger.LogError(ex, "An unexpected error occurred in GetAllProjectsBasic for tenant {TenantId}.", tenantId); return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); } } @@ -134,7 +138,7 @@ namespace Marco.Pms.Services.Service catch (Exception ex) { // --- Step 5: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. \n {Error}", tenantId, ex.Message); + _logger.LogError(ex, "An unexpected error occurred in GetAllProjects for tenant {TenantId}.", tenantId); return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); } } @@ -178,7 +182,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("An unexpected error occurred while getting project {ProjectId} : \n {Error}", id, ex.Message); + _logger.LogError(ex, "An unexpected error occurred while getting project {ProjectId}", id); return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); } } @@ -244,7 +248,7 @@ namespace Marco.Pms.Services.Service catch (Exception ex) { // --- Step 5: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in Get Project Details for project {ProjectId} for tenant {TenantId}. \n {Error}", id, tenantId, ex.Message); + _logger.LogError(ex, "An unexpected error occurred in Get Project Details for project {ProjectId} for tenant {TenantId}. ", id, tenantId); return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); } } @@ -360,7 +364,7 @@ namespace Marco.Pms.Services.Service catch (Exception ex) { // Log the detailed exception - _logger.LogError("Failed to create project in database. Rolling back transaction. \n {Error}", ex.Message); + _logger.LogError(ex, "Failed to create project in database. Rolling back transaction."); // Return a server error as the primary operation failed return ApiResponse.ErrorResponse("An error occurred while saving the project.", ex.Message, 500); } @@ -379,7 +383,7 @@ namespace Marco.Pms.Services.Service { // The project was created successfully, but a side-effect failed. // Log this as a warning, as the primary operation succeeded. Don't return an error to the user. - _logger.LogWarning("Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. \n {Error}", project.Id, ex.Message); + _logger.LogError(ex, "Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. ", project.Id); } // 4. Return a success response to the user as soon as the critical data is saved. @@ -435,7 +439,7 @@ namespace Marco.Pms.Services.Service { // --- Step 3: Handle Concurrency Conflicts --- // This happens if another user modified the project after we fetched it. - _logger.LogWarning("Concurrency conflict while updating project {ProjectId} \n {Error}", id, ex.Message); + _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); } @@ -458,13 +462,216 @@ namespace Marco.Pms.Services.Service catch (Exception ex) { // --- Step 6: Graceful Error Handling for Unexpected Errors --- - _logger.LogError("An unexpected error occurred while updating project {ProjectId} \n {Error}", id, ex.Message); + _logger.LogError(ex, "An unexpected error occurred while updating project {ProjectId} ", id); return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); } } #endregion + #region =================================================================== Project Allocation APIs =================================================================== + + public async Task> GetEmployeeByProjectID(Guid? projectid, bool includeInactive, Guid tenantId, Employee loggedInEmployee) + { + if (projectid == null) + { + return ApiResponse.ErrorResponse("Invalid Input Parameter", 404); + } + // Fetch assigned project + List result = new List(); + + var employeeQuery = _context.ProjectAllocations + .Include(pa => pa.Employee) + .Where(pa => pa.ProjectId == projectid && pa.TenantId == tenantId && pa.Employee != null); + + if (includeInactive) + { + + result = await employeeQuery.Select(pa => pa.Employee ?? new Employee()).ToListAsync(); + } + else + { + result = await employeeQuery + .Where(pa => pa.IsActive) + .Select(pa => pa.Employee ?? new Employee()).ToListAsync(); + } + + List resultVM = new List(); + foreach (Employee employee in result) + { + EmployeeVM vm = _mapper.Map(employee); + resultVM.Add(vm); + } + + return ApiResponse.SuccessResponse(resultVM, "Successfully fetched the list of employees for seleted project", 200); + } + + /// + /// Retrieves a list of employees for a specific project. + /// This method is optimized to perform all filtering and mapping on the database server. + /// + /// The ID of the project. + /// Whether to include employees from inactive allocations. + /// The ID of the current tenant. + /// The current authenticated employee (used for permission checks). + /// An ApiResponse containing a list of employees or an error. + public async Task> GetEmployeeByProjectIdAsync(Guid? projectId, bool includeInactive, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (projectId == null) + { + _logger.LogWarning("GetEmployeeByProjectID called with a null projectId."); + // 400 Bad Request is more appropriate for invalid input than 404 Not Found. + return ApiResponse.ErrorResponse("Project ID is required.", "Invalid Input Parameter", 400); + } + + _logger.LogInfo("Fetching employees for ProjectID: {ProjectId}, IncludeInactive: {IncludeInactive}", projectId, includeInactive); + + try + { + // --- CRITICAL: Security Check --- + // Before fetching data, you MUST verify the user has permission to see it. + // This is a placeholder for your actual permission logic. + var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); + var hasAllEmployeePermission = await _permission.HasPermission(PermissionsMaster.ViewAllEmployees, loggedInEmployee.Id); + var hasviewTeamPermission = await _permission.HasPermission(PermissionsMaster.ViewTeamMembers, loggedInEmployee.Id); + + if (!(hasProjectPermission && (hasAllEmployeePermission || hasviewTeamPermission))) + { + _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to view this project's team.", 403); + } + + // --- Step 2: Build a Single, Efficient IQueryable --- + // We start with the base query and conditionally add filters before executing it. + // This avoids code duplication and is highly performant. + var employeeQuery = _context.ProjectAllocations + .Where(pa => pa.ProjectId == projectId && pa.TenantId == tenantId); + + // Conditionally apply the filter for active allocations. + if (!includeInactive) + { + employeeQuery = employeeQuery.Where(pa => pa.IsActive); + } + + // --- Step 3: Project Directly to the ViewModel on the Database Server --- + // This is the most significant performance optimization. + // Instead of fetching full Employee entities, we select only the data needed for the EmployeeVM. + // AutoMapper's ProjectTo is perfect for this, as it translates the mapping configuration into an efficient SQL SELECT statement. + var resultVM = await employeeQuery + .Where(pa => pa.Employee != null) // Safety check for data integrity + .Select(pa => pa.Employee) // Navigate to the Employee entity + .ProjectTo(_mapper.ConfigurationProvider) // Let AutoMapper generate the SELECT + .ToListAsync(); + + _logger.LogInfo("Successfully fetched {EmployeeCount} employees for project {ProjectId}.", resultVM.Count, projectId); + + // Note: The original mapping loop is now completely gone, replaced by the single efficient query above. + + return ApiResponse.SuccessResponse(resultVM, "Successfully fetched the list of employees for the selected project.", 200); + } + catch (Exception ex) + { + // --- Step 4: Graceful Error Handling --- + _logger.LogError(ex, "An error occurred while fetching employees for project {ProjectId}. ", projectId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "Database Query Failed", 500); + } + } + + public async Task> GetProjectAllocation(Guid? projectId, Guid tenantId, Employee loggedInEmployee) + { + var employees = await _context.ProjectAllocations + .Where(c => c.TenantId == tenantId && c.ProjectId == projectId && c.Employee != null) + .Include(e => e.Employee) + .Select(e => new + { + ID = e.Id, + EmployeeId = e.EmployeeId, + ProjectId = e.ProjectId, + AllocationDate = e.AllocationDate, + ReAllocationDate = e.ReAllocationDate, + FirstName = e.Employee != null ? e.Employee.FirstName : string.Empty, + LastName = e.Employee != null ? e.Employee.LastName : string.Empty, + MiddleName = e.Employee != null ? e.Employee.MiddleName : string.Empty, + IsActive = e.IsActive, + JobRoleId = (e.JobRoleId != null ? e.JobRoleId : e.Employee != null ? e.Employee.JobRoleId : null) + }).ToListAsync(); + + return ApiResponse.SuccessResponse(employees, "Success.", 200); + } + + /// + /// Retrieves project allocation details for a specific project. + /// This method is optimized for performance and includes security checks. + /// + /// The ID of the project. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing allocation details or an appropriate error. + public async Task> GetProjectAllocationAsync(Guid? projectId, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (projectId == null) + { + _logger.LogWarning("GetProjectAllocation called with a null projectId."); + return ApiResponse.ErrorResponse("Project ID is required.", "Invalid Input Parameter", 400); + } + + _logger.LogInfo("Fetching allocations for ProjectID: {ProjectId} for user {UserId}", projectId, loggedInEmployee.Id); + + try + { + // --- Step 2: Security and Existence Checks --- + // Before fetching data, you MUST verify the user has permission to see it. + // This is a placeholder for your actual permission logic. + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to view this project's team.", 403); + } + + // --- Step 3: Execute a Single, Optimized Database Query --- + // This query projects directly to a new object on the database server, which is highly efficient. + var allocations = await _context.ProjectAllocations + // Filter down to the relevant records first. + .Where(pa => pa.ProjectId == projectId && pa.TenantId == tenantId && pa.Employee != null) + // Project directly to the final shape. This tells EF Core which columns to select. + // The redundant .Include() is removed as EF Core infers the JOIN from this Select. + .Select(pa => new + { + // Fields from ProjectAllocation + ID = pa.Id, + pa.EmployeeId, + pa.ProjectId, + pa.AllocationDate, + pa.ReAllocationDate, + pa.IsActive, + + // Fields from the joined Employee table (no null checks needed due to the 'Where' clause) + FirstName = pa.Employee!.FirstName, + LastName = pa.Employee.LastName, + MiddleName = pa.Employee.MiddleName, + + // Simplified JobRoleId logic: Use the allocation's role if it exists, otherwise fall back to the employee's default role. + JobRoleId = pa.JobRoleId ?? pa.Employee.JobRoleId + }) + .ToListAsync(); + + _logger.LogInfo("Successfully fetched {AllocationCount} allocations for project {ProjectId}.", allocations.Count, projectId); + + return ApiResponse.SuccessResponse(allocations, "Project allocations retrieved successfully.", 200); + } + catch (Exception ex) + { + // --- Step 4: Graceful Error Handling --- + // Log the full exception for debugging, but return a generic, safe error message. + _logger.LogError(ex, "An error occurred while fetching allocations for project {ProjectId}.", projectId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "Database query failed.", 500); + } + } + #endregion + #region =================================================================== Helper Functions =================================================================== /// @@ -661,7 +868,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogWarning("Failed to update cache for project {ProjectId} : \n {Error}", projectId, ex.Message); + _logger.LogError(ex, "Failed to update cache for project {ProjectId} : ", projectId); } // Map from the database entity to the response ViewModel. @@ -682,7 +889,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("Background cache update failed for project {ProjectId} \n {Error}", project.Id, ex.Message); + _logger.LogError(ex, "Background cache update failed for project {ProjectId} ", project.Id); } } diff --git a/Marco.Pms.Services/Service/RefreshTokenService.cs b/Marco.Pms.Services/Service/RefreshTokenService.cs index 231e27c..84ef3fd 100644 --- a/Marco.Pms.Services/Service/RefreshTokenService.cs +++ b/Marco.Pms.Services/Service/RefreshTokenService.cs @@ -1,11 +1,11 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; #nullable disable namespace MarcoBMS.Services.Service @@ -94,7 +94,7 @@ namespace MarcoBMS.Services.Service } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occured while creating new JWT token for user {UserId}", userId); throw; } } @@ -132,7 +132,7 @@ namespace MarcoBMS.Services.Service } catch (Exception ex) { - _logger.LogError("Error creating MPIN token for userId: {UserId}, tenantId: {TenantId}, error : {Error}", userId, tenantId, ex.Message); + _logger.LogError(ex, "Error creating MPIN token for userId: {UserId}, tenantId: {TenantId}", userId, tenantId); throw; } } @@ -218,7 +218,7 @@ namespace MarcoBMS.Services.Service catch (Exception ex) { // Token is invalid - _logger.LogError($"Token validation failed: {ex.Message}"); + _logger.LogError(ex, "Token validation failed"); return null; } } diff --git a/Marco.Pms.Services/Service/S3UploadService.cs b/Marco.Pms.Services/Service/S3UploadService.cs index c29cfdd..4ce7a4b 100644 --- a/Marco.Pms.Services/Service/S3UploadService.cs +++ b/Marco.Pms.Services/Service/S3UploadService.cs @@ -64,7 +64,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("{error} while uploading file to S3", ex.Message); + _logger.LogError(ex, "error occured while uploading file to S3"); } @@ -87,7 +87,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("{error} while requesting presigned url from Amazon S3", ex.Message); + _logger.LogError(ex, "error occured while requesting presigned url from Amazon S3", ex.Message); return string.Empty; } } @@ -107,7 +107,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("{error} while deleting from Amazon S3", ex.Message); + _logger.LogError(ex, "error ocured while deleting from Amazon S3"); return false; } } @@ -202,7 +202,7 @@ namespace Marco.Pms.Services.Service } else { - _logger.LogError("Warning: Could not find MimeType, Type, or ContentType property in Definition."); + _logger.LogWarning("Warning: Could not find MimeType, Type, or ContentType property in Definition."); return "application/octet-stream"; } } @@ -211,16 +211,16 @@ namespace Marco.Pms.Services.Service return "application/octet-stream"; // Default if type cannot be determined } } - catch (FormatException) + catch (FormatException fEx) { // Handle cases where the input string is not valid Base64 - _logger.LogError("Invalid Base64 string."); + _logger.LogError(fEx, "Invalid Base64 string."); return string.Empty; } catch (Exception ex) { // Handle other potential errors during decoding or inspection - _logger.LogError($"An error occurred: {ex.Message}"); + _logger.LogError(ex, "errors during decoding or inspection"); return string.Empty; } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index a23eba0..d0539b0 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -13,5 +13,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> GetProjectDetailsOldAsync(Guid id, Guid tenantId, Employee loggedInEmployee); Task> CreateProjectAsync(CreateProjectDto projectDto, Guid tenantId, Employee loggedInEmployee); Task> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee); + Task> GetEmployeeByProjectIdAsync(Guid? projectId, bool includeInactive, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectAllocationAsync(Guid? projectId, Guid tenantId, Employee loggedInEmployee); } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/ISignalRService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/ISignalRService.cs new file mode 100644 index 0000000..c37322b --- /dev/null +++ b/Marco.Pms.Services/Service/ServiceInterfaces/ISignalRService.cs @@ -0,0 +1,7 @@ +namespace Marco.Pms.Services.Service.ServiceInterfaces +{ + public interface ISignalRService + { + Task SendNotificationAsync(object notification); + } +} diff --git a/Marco.Pms.Services/Service/SignalRService.cs b/Marco.Pms.Services/Service/SignalRService.cs new file mode 100644 index 0000000..fecc9b0 --- /dev/null +++ b/Marco.Pms.Services/Service/SignalRService.cs @@ -0,0 +1,29 @@ +using Marco.Pms.Services.Hubs; +using Marco.Pms.Services.Service.ServiceInterfaces; +using MarcoBMS.Services.Service; +using Microsoft.AspNetCore.SignalR; + +namespace Marco.Pms.Services.Service +{ + public class SignalRService : ISignalRService + { + private readonly IHubContext _signalR; + private readonly ILoggingService _logger; + public SignalRService(IHubContext signalR, ILoggingService logger) + { + _signalR = signalR ?? throw new ArgumentNullException(nameof(signalR)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + public async Task SendNotificationAsync(object notification) + { + try + { + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception occured during sending notification through signalR"); + } + } + } +} -- 2.43.0 From 80149f05f78ade88758d9508554980972744f93d Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 13:09:27 +0530 Subject: [PATCH 33/50] Solved the issue of project is not updating properly --- Marco.Pms.CacheHelper/ProjectCache.cs | 4 +- .../Controllers/ProjectController.cs | 101 +++-------- Marco.Pms.Services/Service/ProjectServices.cs | 158 ++++++++++-------- 3 files changed, 111 insertions(+), 152 deletions(-) diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index 183bbc4..c7d7e84 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -42,8 +42,8 @@ namespace Marco.Pms.CacheHelper Builders.Update.Set(r => r.ShortName, project.ShortName), Builders.Update.Set(r => r.ProjectStatus, new StatusMasterMongoDB { - Id = projectStatus?.Id.ToString(), - Status = projectStatus?.Status + Id = projectStatus.Id.ToString(), + Status = projectStatus.Status }), Builders.Update.Set(r => r.StartDate, project.StartDate), Builders.Update.Set(r => r.EndDate, project.EndDate), diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 236e0cb..0122003 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -252,89 +252,28 @@ namespace MarcoBMS.Services.Controllers return StatusCode(response.StatusCode, response); } - [HttpPost("allocation")] - public async Task ManageAllocation(List projectAllocationDot) - { - if (projectAllocationDot != null) - { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + //[HttpPost("allocation")] + //public async Task ManageAllocation(List projectAllocationDot) + //{ + // // --- Step 1: Input Validation --- + // if (!ModelState.IsValid) + // { + // var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + // _logger.LogWarning("Update project called with invalid model state for ID {ProjectId}. Errors: {Errors}", id, string.Join(", ", errors)); + // return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + // } - List? result = new List(); - List employeeIds = new List(); - List projectIds = new List(); + // // --- Step 2: Prepare data without I/O --- + // Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + // var response = await _projectServices.UpdateProjectAsync(id, updateProjectDto, tenantId, loggedInEmployee); + // if (response.Success) + // { + // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; + // await _signalR.SendNotificationAsync(notification); + // } + // return StatusCode(response.StatusCode, response); - foreach (var item in projectAllocationDot) - { - try - { - ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); - ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == projectAllocation.EmployeeId - && c.ProjectId == projectAllocation.ProjectId - && c.ReAllocationDate == null - && c.TenantId == tenantId).SingleOrDefaultAsync(); - - if (projectAllocationFromDb != null) - { - _context.ProjectAllocations.Attach(projectAllocationFromDb); - - if (item.Status) - { - projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; - projectAllocationFromDb.IsActive = true; - _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - } - else - { - projectAllocationFromDb.ReAllocationDate = DateTime.Now; - projectAllocationFromDb.IsActive = false; - _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - - employeeIds.Add(projectAllocation.EmployeeId); - projectIds.Add(projectAllocation.ProjectId); - } - await _context.SaveChangesAsync(); - var result1 = new - { - Id = projectAllocationFromDb.Id, - EmployeeId = projectAllocation.EmployeeId, - JobRoleId = projectAllocation.JobRoleId, - IsActive = projectAllocation.IsActive, - ProjectId = projectAllocation.ProjectId, - AllocationDate = projectAllocation.AllocationDate, - ReAllocationDate = projectAllocation.ReAllocationDate, - TenantId = projectAllocation.TenantId - }; - result.Add(result1); - } - else - { - projectAllocation.AllocationDate = DateTime.Now; - projectAllocation.IsActive = true; - _context.ProjectAllocations.Add(projectAllocation); - await _context.SaveChangesAsync(); - - employeeIds.Add(projectAllocation.EmployeeId); - projectIds.Add(projectAllocation.ProjectId); - } - await _cache.ClearAllProjectIds(item.EmpID); - - } - catch (Exception ex) - { - return Ok(ApiResponse.ErrorResponse(ex.Message, ex, 400)); - } - } - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; - - await _signalR.SendNotificationAsync(notification); - return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); - - } - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Work Item Details are not valid.", 400)); - - } + //} [HttpGet("assigned-projects/{employeeId}")] public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index dcaf20e..7717584 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -443,21 +443,16 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409); } - // --- Step 4: Perform Side-Effects in the Background (Fire and Forget) --- - // The core database operation is done. Now, we perform non-blocking cache and notification updates. - _ = Task.Run(async () => - { - // Create a DTO of the updated project to pass to background tasks. - var projectDto = _mapper.Map(existingProject); + // --- Step 4: Perform Side-Effects (Fire and Forget) --- + // Create a DTO of the updated project to pass to background tasks. + var projectDto = _mapper.Map(existingProject); - // 4a. Update Cache - await UpdateCacheInBackground(existingProject); - - }); + // 4a. Update Cache + await UpdateCacheInBackground(existingProject); // --- Step 5: Return Success Response Immediately --- // The client gets a fast response without waiting for caching or SignalR. - return ApiResponse.SuccessResponse(_mapper.Map(existingProject), "Project updated successfully.", 200); + return ApiResponse.SuccessResponse(projectDto, "Project updated successfully.", 200); } catch (Exception ex) { @@ -471,41 +466,6 @@ namespace Marco.Pms.Services.Service #region =================================================================== Project Allocation APIs =================================================================== - public async Task> GetEmployeeByProjectID(Guid? projectid, bool includeInactive, Guid tenantId, Employee loggedInEmployee) - { - if (projectid == null) - { - return ApiResponse.ErrorResponse("Invalid Input Parameter", 404); - } - // Fetch assigned project - List result = new List(); - - var employeeQuery = _context.ProjectAllocations - .Include(pa => pa.Employee) - .Where(pa => pa.ProjectId == projectid && pa.TenantId == tenantId && pa.Employee != null); - - if (includeInactive) - { - - result = await employeeQuery.Select(pa => pa.Employee ?? new Employee()).ToListAsync(); - } - else - { - result = await employeeQuery - .Where(pa => pa.IsActive) - .Select(pa => pa.Employee ?? new Employee()).ToListAsync(); - } - - List resultVM = new List(); - foreach (Employee employee in result) - { - EmployeeVM vm = _mapper.Map(employee); - resultVM.Add(vm); - } - - return ApiResponse.SuccessResponse(resultVM, "Successfully fetched the list of employees for seleted project", 200); - } - /// /// Retrieves a list of employees for a specific project. /// This method is optimized to perform all filtering and mapping on the database server. @@ -578,28 +538,6 @@ namespace Marco.Pms.Services.Service } } - public async Task> GetProjectAllocation(Guid? projectId, Guid tenantId, Employee loggedInEmployee) - { - var employees = await _context.ProjectAllocations - .Where(c => c.TenantId == tenantId && c.ProjectId == projectId && c.Employee != null) - .Include(e => e.Employee) - .Select(e => new - { - ID = e.Id, - EmployeeId = e.EmployeeId, - ProjectId = e.ProjectId, - AllocationDate = e.AllocationDate, - ReAllocationDate = e.ReAllocationDate, - FirstName = e.Employee != null ? e.Employee.FirstName : string.Empty, - LastName = e.Employee != null ? e.Employee.LastName : string.Empty, - MiddleName = e.Employee != null ? e.Employee.MiddleName : string.Empty, - IsActive = e.IsActive, - JobRoleId = (e.JobRoleId != null ? e.JobRoleId : e.Employee != null ? e.Employee.JobRoleId : null) - }).ToListAsync(); - - return ApiResponse.SuccessResponse(employees, "Success.", 200); - } - /// /// Retrieves project allocation details for a specific project. /// This method is optimized for performance and includes security checks. @@ -670,6 +608,87 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("An internal server error occurred.", "Database query failed.", 500); } } + + //public async Task> ManageAllocation(List projectAllocationDot, Guid tenantId, Employee loggedInEmployee) + //{ + // if (projectAllocationDot != null) + // { + // List? result = new List(); + // List employeeIds = new List(); + // List projectIds = new List(); + + // foreach (var item in projectAllocationDot) + // { + // try + // { + // //ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); + // ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); + // ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == projectAllocation.EmployeeId + // && c.ProjectId == projectAllocation.ProjectId + // && c.ReAllocationDate == null + // && c.TenantId == tenantId).SingleOrDefaultAsync(); + + // if (projectAllocationFromDb != null) + // { + // _context.ProjectAllocations.Attach(projectAllocationFromDb); + + // if (item.Status) + // { + // projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; + // projectAllocationFromDb.IsActive = true; + // _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; + // _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; + // } + // else + // { + // projectAllocationFromDb.ReAllocationDate = DateTime.Now; + // projectAllocationFromDb.IsActive = false; + // _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; + // _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; + + // employeeIds.Add(projectAllocation.EmployeeId); + // projectIds.Add(projectAllocation.ProjectId); + // } + // await _context.SaveChangesAsync(); + // var result1 = new + // { + // Id = projectAllocationFromDb.Id, + // EmployeeId = projectAllocation.EmployeeId, + // JobRoleId = projectAllocation.JobRoleId, + // IsActive = projectAllocation.IsActive, + // ProjectId = projectAllocation.ProjectId, + // AllocationDate = projectAllocation.AllocationDate, + // ReAllocationDate = projectAllocation.ReAllocationDate, + // TenantId = projectAllocation.TenantId + // }; + // result.Add(result1); + // } + // else + // { + // projectAllocation.AllocationDate = DateTime.Now; + // projectAllocation.IsActive = true; + // _context.ProjectAllocations.Add(projectAllocation); + // await _context.SaveChangesAsync(); + + // employeeIds.Add(projectAllocation.EmployeeId); + // projectIds.Add(projectAllocation.ProjectId); + // } + // await _cache.ClearAllProjectIds(item.EmpID); + + // } + // catch (Exception ex) + // { + // return ApiResponse.ErrorResponse(ex.Message, ex, 400); + // } + // } + + // return ApiResponse.SuccessResponse(result, "Data saved successfully", 200); + + // } + // return ApiResponse.ErrorResponse("Invalid details.", "Work Item Details are not valid.", 400); + + //} + #endregion #region =================================================================== Helper Functions =================================================================== @@ -881,7 +900,8 @@ namespace Marco.Pms.Services.Service try { // This logic can be more complex, but the idea is to update or add. - if (!await _cache.UpdateProjectDetailsOnly(project)) + var demo = await _cache.UpdateProjectDetailsOnly(project); + if (!demo) { await _cache.AddProjectDetails(project); } -- 2.43.0 From 7914cf20d44c1816f0f754a67ccae984a73ded0f Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 14:34:26 +0530 Subject: [PATCH 34/50] Removed unused code from employee cache class --- Marco.Pms.CacheHelper/EmployeeCache.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Marco.Pms.CacheHelper/EmployeeCache.cs b/Marco.Pms.CacheHelper/EmployeeCache.cs index 4a668f0..2211393 100644 --- a/Marco.Pms.CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.CacheHelper/EmployeeCache.cs @@ -1,5 +1,4 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.MongoDBModels; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using MongoDB.Driver; @@ -8,13 +7,10 @@ namespace Marco.Pms.CacheHelper { public class EmployeeCache { - private readonly ApplicationDbContext _context; - //private readonly IMongoDatabase _mongoDB; private readonly IMongoCollection _collection; - public EmployeeCache(ApplicationDbContext context, IConfiguration configuration) + public EmployeeCache(IConfiguration configuration) { var connectionString = configuration["MongoDB:ConnectionString"]; - _context = context; var mongoUrl = new MongoUrl(connectionString); var client = new MongoClient(mongoUrl); // Your MongoDB connection string var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name -- 2.43.0 From 72dccc0c6a7090b81d04079e31afe6b0609e66ad Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 15:21:48 +0530 Subject: [PATCH 35/50] Added Employee ID of creater to bucket in Employee IDs --- Marco.Pms.Services/Helpers/DirectoryHelper.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Marco.Pms.Services/Helpers/DirectoryHelper.cs b/Marco.Pms.Services/Helpers/DirectoryHelper.cs index ad9001c..2963ff2 100644 --- a/Marco.Pms.Services/Helpers/DirectoryHelper.cs +++ b/Marco.Pms.Services/Helpers/DirectoryHelper.cs @@ -1184,7 +1184,11 @@ namespace Marco.Pms.Services.Helpers var emplyeeIds = employeeBucketMappings.Select(eb => eb.EmployeeId).ToList(); List? contactBuckets = contactBucketMappings.Where(cb => cb.BucketId == bucket.Id).ToList(); AssignBucketVM bucketVM = bucket.ToAssignBucketVMFromBucket(); - bucketVM.EmployeeIds = emplyeeIds; + if (bucketVM.CreatedBy != null) + { + emplyeeIds.Add(bucketVM.CreatedBy.Id); + } + bucketVM.EmployeeIds = emplyeeIds.Distinct().ToList(); bucketVM.NumberOfContacts = contactBuckets.Count; bucketVMs.Add(bucketVM); } -- 2.43.0 From 168922c278dd5777de8641fd05cd8e955f8f106f Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 15:30:41 +0530 Subject: [PATCH 36/50] Optimized the Project Allocation API --- Marco.Pms.CacheHelper/EmployeeCache.cs | 2 +- .../Projects/ProjectAllocationVM.cs | 13 ++ .../Controllers/ProjectController.cs | 43 ++--- .../MappingProfiles/MappingProfile.cs | 6 + Marco.Pms.Services/Service/ProjectServices.cs | 167 ++++++++++-------- .../ServiceInterfaces/IProjectServices.cs | 2 + 6 files changed, 142 insertions(+), 91 deletions(-) create mode 100644 Marco.Pms.Model/ViewModels/Projects/ProjectAllocationVM.cs diff --git a/Marco.Pms.CacheHelper/EmployeeCache.cs b/Marco.Pms.CacheHelper/EmployeeCache.cs index 2211393..f7b7066 100644 --- a/Marco.Pms.CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.CacheHelper/EmployeeCache.cs @@ -97,7 +97,7 @@ namespace Marco.Pms.CacheHelper var result = await _collection.UpdateOneAsync(filter, update); - if (result.MatchedCount == 0) + if (result.ModifiedCount == 0) return false; return true; diff --git a/Marco.Pms.Model/ViewModels/Projects/ProjectAllocationVM.cs b/Marco.Pms.Model/ViewModels/Projects/ProjectAllocationVM.cs new file mode 100644 index 0000000..6d9138e --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Projects/ProjectAllocationVM.cs @@ -0,0 +1,13 @@ +namespace Marco.Pms.Model.ViewModels.Projects +{ + public class ProjectAllocationVM + { + public Guid Id { get; set; } + public Guid EmployeeId { get; set; } + public Guid? JobRoleId { get; set; } + public bool IsActive { get; set; } = true; + public Guid ProjectId { get; set; } + public DateTime AllocationDate { get; set; } + public DateTime? ReAllocationDate { get; set; } + } +} diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 0122003..b833064 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -252,28 +252,31 @@ namespace MarcoBMS.Services.Controllers return StatusCode(response.StatusCode, response); } - //[HttpPost("allocation")] - //public async Task ManageAllocation(List projectAllocationDot) - //{ - // // --- Step 1: Input Validation --- - // if (!ModelState.IsValid) - // { - // var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); - // _logger.LogWarning("Update project called with invalid model state for ID {ProjectId}. Errors: {Errors}", id, string.Join(", ", errors)); - // return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); - // } + [HttpPost("allocation")] + public async Task ManageAllocation([FromBody] List projectAllocationDot) + { + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) + { + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } - // // --- Step 2: Prepare data without I/O --- - // Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - // var response = await _projectServices.UpdateProjectAsync(id, updateProjectDto, tenantId, loggedInEmployee); - // if (response.Success) - // { - // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; - // await _signalR.SendNotificationAsync(notification); - // } - // return StatusCode(response.StatusCode, response); + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.ManageAllocationAsync(projectAllocationDot, tenantId, loggedInEmployee); + if (response.Success) + { + List employeeIds = response.Data.Select(pa => pa.EmployeeId).ToList(); + List projectIds = response.Data.Select(pa => pa.ProjectId).ToList(); - //} + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; + await _signalR.SendNotificationAsync(notification); + } + return StatusCode(response.StatusCode, response); + + } [HttpGet("assigned-projects/{employeeId}")] public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 7d627bc..3ca1271 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -43,6 +43,12 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); CreateMap(); + CreateMap() + .ForMember( + dest => dest.EmployeeId, + // Explicitly and safely convert string ProjectStatusId to Guid ProjectStatusId + opt => opt.MapFrom(src => src.EmpID)); + CreateMap(); #endregion #region ======================================================= Projects ======================================================= diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 7717584..33df2c0 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -609,85 +609,112 @@ namespace Marco.Pms.Services.Service } } - //public async Task> ManageAllocation(List projectAllocationDot, Guid tenantId, Employee loggedInEmployee) - //{ - // if (projectAllocationDot != null) - // { - // List? result = new List(); - // List employeeIds = new List(); - // List projectIds = new List(); + /// + /// Manages project allocations for a list of employees, either adding new allocations or deactivating existing ones. + /// This method is optimized to perform all database operations in a single transaction. + /// + /// The list of allocation changes to process. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing the list of processed allocations. + public async Task>> ManageAllocationAsync(List allocationsDto, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (allocationsDto == null || !allocationsDto.Any()) + { + return ApiResponse>.ErrorResponse("Invalid details.", "Allocation details list cannot be null or empty.", 400); + } - // foreach (var item in projectAllocationDot) - // { - // try - // { - // //ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); - // ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(tenantId); - // ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == projectAllocation.EmployeeId - // && c.ProjectId == projectAllocation.ProjectId - // && c.ReAllocationDate == null - // && c.TenantId == tenantId).SingleOrDefaultAsync(); + _logger.LogInfo("Starting to manage {AllocationCount} allocations for user {UserId}.", allocationsDto.Count, loggedInEmployee.Id); - // if (projectAllocationFromDb != null) - // { - // _context.ProjectAllocations.Attach(projectAllocationFromDb); + // --- (Placeholder) Security Check --- + // In a real application, you would check if the loggedInEmployee has permission + // to manage allocations for ALL projects involved in this batch. + var projectIdsInBatch = allocationsDto.Select(a => a.ProjectId).Distinct().ToList(); + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageTeam, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to manage allocations for projects.", loggedInEmployee.Id); + return ApiResponse>.ErrorResponse("Access Denied.", "You do not have permission to manage one or more projects in this request.", 403); + } - // if (item.Status) - // { - // projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; - // projectAllocationFromDb.IsActive = true; - // _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; - // _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - // } - // else - // { - // projectAllocationFromDb.ReAllocationDate = DateTime.Now; - // projectAllocationFromDb.IsActive = false; - // _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; - // _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; + // --- Step 2: Fetch all relevant existing data in ONE database call --- + var employeeProjectPairs = allocationsDto.Select(a => new { a.EmpID, a.ProjectId }).ToList(); + List employeeIds = allocationsDto.Select(a => a.EmpID).Distinct().ToList(); - // employeeIds.Add(projectAllocation.EmployeeId); - // projectIds.Add(projectAllocation.ProjectId); - // } - // await _context.SaveChangesAsync(); - // var result1 = new - // { - // Id = projectAllocationFromDb.Id, - // EmployeeId = projectAllocation.EmployeeId, - // JobRoleId = projectAllocation.JobRoleId, - // IsActive = projectAllocation.IsActive, - // ProjectId = projectAllocation.ProjectId, - // AllocationDate = projectAllocation.AllocationDate, - // ReAllocationDate = projectAllocation.ReAllocationDate, - // TenantId = projectAllocation.TenantId - // }; - // result.Add(result1); - // } - // else - // { - // projectAllocation.AllocationDate = DateTime.Now; - // projectAllocation.IsActive = true; - // _context.ProjectAllocations.Add(projectAllocation); - // await _context.SaveChangesAsync(); + // Fetch all currently active allocations for the employees and projects in this batch. + // We use a dictionary for fast O(1) lookups inside the loop. + var existingAllocations = await _context.ProjectAllocations + .Where(pa => pa.TenantId == tenantId && + employeeIds.Contains(pa.EmployeeId) && + pa.ReAllocationDate == null) + .ToDictionaryAsync(pa => (pa.EmployeeId, pa.ProjectId)); - // employeeIds.Add(projectAllocation.EmployeeId); - // projectIds.Add(projectAllocation.ProjectId); - // } - // await _cache.ClearAllProjectIds(item.EmpID); + var processedAllocations = new List(); - // } - // catch (Exception ex) - // { - // return ApiResponse.ErrorResponse(ex.Message, ex, 400); - // } - // } + // --- Step 3: Process logic IN MEMORY --- + foreach (var dto in allocationsDto) + { + var key = (dto.EmpID, dto.ProjectId); + existingAllocations.TryGetValue(key, out var existingAllocation); - // return ApiResponse.SuccessResponse(result, "Data saved successfully", 200); + if (dto.Status == false) // User wants to DEACTIVATE the allocation + { + if (existingAllocation != null) + { + // Mark the existing allocation for deactivation + existingAllocation.ReAllocationDate = DateTime.UtcNow; // Use UtcNow for servers + existingAllocation.IsActive = false; + _context.ProjectAllocations.Update(existingAllocation); + processedAllocations.Add(existingAllocation); + } + // If it doesn't exist, we do nothing. The desired state is "not allocated". + } + else // User wants to ACTIVATE the allocation + { + if (existingAllocation == null) + { + // Create a new allocation because one doesn't exist + var newAllocation = _mapper.Map(dto); + newAllocation.TenantId = tenantId; + newAllocation.AllocationDate = DateTime.UtcNow; + newAllocation.IsActive = true; + _context.ProjectAllocations.Add(newAllocation); + processedAllocations.Add(newAllocation); + } + // If it already exists and is active, we do nothing. The state is already correct. + } + try + { + await _cache.ClearAllProjectIds(dto.EmpID); + _logger.LogInfo("Successfully completed cache invalidation for employee {EmployeeId}.", dto.EmpID); + } + catch (Exception ex) + { + // Log the error but don't fail the entire request, as the primary DB operation succeeded. + _logger.LogError(ex, "Cache invalidation failed for employees after a successful database update."); + } + } - // } - // return ApiResponse.ErrorResponse("Invalid details.", "Work Item Details are not valid.", 400); + try + { + // --- Step 4: Save all changes in a SINGLE TRANSACTION --- + // All Adds and Updates are sent to the database in one batch. + // If any part fails, the entire transaction is rolled back. + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully saved {ChangeCount} allocation changes to the database.", processedAllocations.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save allocation changes to the database."); + return ApiResponse>.ErrorResponse("Database Error.", "An error occurred while saving the changes.", 500); + } - //} + + // --- Step 5: Map results and return success --- + var resultVm = _mapper.Map>(processedAllocations); + return ApiResponse>.SuccessResponse(resultVm, "Allocations managed successfully.", 200); + } #endregion diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index d0539b0..2552444 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -1,6 +1,7 @@ using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Utilities; +using Marco.Pms.Model.ViewModels.Projects; namespace Marco.Pms.Services.Service.ServiceInterfaces { @@ -15,5 +16,6 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee); Task> GetEmployeeByProjectIdAsync(Guid? projectId, bool includeInactive, Guid tenantId, Employee loggedInEmployee); Task> GetProjectAllocationAsync(Guid? projectId, Guid tenantId, Employee loggedInEmployee); + Task>> ManageAllocationAsync(List projectAllocationDots, Guid tenantId, Employee loggedInEmployee); } } -- 2.43.0 From c03fae4b65dd4d4fe58e6cf79d850d90cb136808 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 15:37:15 +0530 Subject: [PATCH 37/50] Added Sonar files in git ignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9491a2f..a6a47c3 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +# Sonar +/.sonarqube \ No newline at end of file -- 2.43.0 From c90f39082a76e0e2a9cd49d025ee30fd8cd6e6a0 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 15 Jul 2025 16:37:57 +0530 Subject: [PATCH 38/50] Optimized the project allocation by employee Id Apis --- .../Controllers/ProjectController.cs | 130 +++---------- .../MappingProfiles/MappingProfile.cs | 1 + Marco.Pms.Services/Service/ProjectServices.cs | 180 ++++++++++++++++++ .../ServiceInterfaces/IProjectServices.cs | 2 + 4 files changed, 207 insertions(+), 106 deletions(-) diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index b833064..82ce0dd 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -17,7 +17,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; using MongoDB.Driver; -using Project = Marco.Pms.Model.Projects.Project; namespace MarcoBMS.Services.Controllers { @@ -281,123 +280,42 @@ namespace MarcoBMS.Services.Controllers [HttpGet("assigned-projects/{employeeId}")] public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) { - if (employeeId == Guid.Empty) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Employee id not valid.", 400)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get project list by employee Id called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - List projectList = await _context.ProjectAllocations - .Where(c => c.TenantId == tenantId && c.EmployeeId == employeeId && c.IsActive) - .Select(c => c.ProjectId).Distinct() - .ToListAsync(); - - if (!projectList.Any()) - { - return NotFound(ApiResponse.SuccessResponse(new List(), "No projects found.", 200)); - } - - - List projectlist = await _context.Projects - .Where(p => projectList.Contains(p.Id)) - .ToListAsync(); - - List projects = new List(); - - - foreach (var project in projectlist) - { - - projects.Add(project.ToProjectListVMFromProject()); - } - - - - return Ok(ApiResponse.SuccessResponse(projects, "Success.", 200)); + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectsByEmployeeAsync(employeeId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpPost("assign-projects/{employeeId}")] - public async Task AssigneProjectsToEmployee([FromBody] List projectAllocationDtos, [FromRoute] Guid employeeId) + public async Task AssigneProjectsToEmployee([FromBody] List projectAllocationDtos, [FromRoute] Guid employeeId) { - if (projectAllocationDtos != null && employeeId != Guid.Empty) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - List? result = new List(); - List projectIds = new List(); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } - foreach (var projectAllocationDto in projectAllocationDtos) - { - try - { - ProjectAllocation projectAllocation = projectAllocationDto.ToProjectAllocationFromProjectsAllocationDto(tenantId, employeeId); - ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.ProjectId == projectAllocationDto.ProjectId && c.ReAllocationDate == null && c.TenantId == tenantId).SingleOrDefaultAsync(); - - if (projectAllocationFromDb != null) - { - - - _context.ProjectAllocations.Attach(projectAllocationFromDb); - - if (projectAllocationDto.Status) - { - projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; - projectAllocationFromDb.IsActive = true; - _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - } - else - { - projectAllocationFromDb.ReAllocationDate = DateTime.UtcNow; - projectAllocationFromDb.IsActive = false; - _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - - projectIds.Add(projectAllocation.ProjectId); - } - await _context.SaveChangesAsync(); - var result1 = new - { - Id = projectAllocationFromDb.Id, - EmployeeId = projectAllocation.EmployeeId, - JobRoleId = projectAllocation.JobRoleId, - IsActive = projectAllocation.IsActive, - ProjectId = projectAllocation.ProjectId, - AllocationDate = projectAllocation.AllocationDate, - ReAllocationDate = projectAllocation.ReAllocationDate, - TenantId = projectAllocation.TenantId - }; - result.Add(result1); - } - else - { - projectAllocation.AllocationDate = DateTime.Now; - projectAllocation.IsActive = true; - _context.ProjectAllocations.Add(projectAllocation); - await _context.SaveChangesAsync(); - - projectIds.Add(projectAllocation.ProjectId); - - } - - - } - catch (Exception ex) - { - - return Ok(ApiResponse.ErrorResponse(ex.Message, ex, 400)); - } - } - await _cache.ClearAllProjectIds(employeeId); - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.AssigneProjectsToEmployeeAsync(projectAllocationDtos, employeeId, tenantId, loggedInEmployee); + if (response.Success) + { + List projectIds = response.Data.Select(pa => pa.ProjectId).ToList(); + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; await _signalR.SendNotificationAsync(notification); - - return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); } - else - { - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "All Field is required", 400)); - } - + return StatusCode(response.StatusCode, response); } #endregion diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 3ca1271..ea42d16 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -48,6 +48,7 @@ namespace Marco.Pms.Services.MappingProfiles dest => dest.EmployeeId, // Explicitly and safely convert string ProjectStatusId to Guid ProjectStatusId opt => opt.MapFrom(src => src.EmpID)); + CreateMap(); CreateMap(); #endregion diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 33df2c0..9024112 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -716,6 +716,186 @@ namespace Marco.Pms.Services.Service return ApiResponse>.SuccessResponse(resultVm, "Allocations managed successfully.", 200); } + /// + /// Retrieves a list of active projects assigned to a specific employee. + /// + /// The ID of the employee whose projects are being requested. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing a list of basic project details or an error. + public async Task> GetProjectsByEmployeeAsync(Guid employeeId, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (employeeId == Guid.Empty) + { + return ApiResponse.ErrorResponse("Invalid details.", "A valid employee ID is required.", 400); + } + + _logger.LogInfo("Fetching projects for Employee {EmployeeId} by User {UserId}", employeeId, loggedInEmployee.Id); + + try + { + // --- Step 2: Clarified Security Check --- + // The permission should be about viewing another employee's assignments, not a generic "Manage Team". + // This is a placeholder for your actual, more specific permission logic. + // It should also handle the case where a user is requesting their own projects (employeeId == loggedInEmployee.Id). + var hasPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); + var projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to view projects for employee {TargetEmployeeId}.", loggedInEmployee.Id, employeeId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to view this employee's projects.", 403); + } + + // --- Step 3: Execute a Single, Highly Efficient Database Query --- + // This query projects directly to the ViewModel on the database server. + var projects = await _context.ProjectAllocations + // 1. Filter the linking table down to the relevant records. + .Where(pa => + pa.TenantId == tenantId && + pa.EmployeeId == employeeId && // Target the specified employee + pa.IsActive && // Only active assignments + projectIds.Contains(pa.ProjectId) && + pa.Project != null) // Safety check for data integrity + + // 2. Navigate to the Project entity. + .Select(pa => pa.Project) + + // 3. Ensure the final result set is unique (in case of multiple active allocations to the same project). + .Distinct() + + // 4. Project directly to the ViewModel using AutoMapper's IQueryable Extensions. + // This generates an efficient SQL "SELECT Id, Name, Code FROM..." statement. + .ProjectTo(_mapper.ConfigurationProvider) + + // 5. Execute the query. + .ToListAsync(); + + _logger.LogInfo("Successfully retrieved {ProjectCount} projects for employee {EmployeeId}.", projects.Count, employeeId); + + // The original check for an empty list is still good practice. + if (!projects.Any()) + { + return ApiResponse.SuccessResponse(new List(), "No active projects found for this employee.", 200); + } + + return ApiResponse.SuccessResponse(projects, "Projects retrieved successfully.", 200); + } + catch (Exception ex) + { + // --- Step 4: Graceful Error Handling --- + _logger.LogError(ex, "An error occurred while fetching projects for employee {EmployeeId}.", employeeId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "Database query failed.", 500); + } + } + + /// + /// Manages project assignments for a single employee, processing a batch of projects to activate or deactivate. + /// This method is optimized to perform all database operations in a single, atomic transaction. + /// + /// A list of projects to assign or un-assign. + /// The ID of the employee whose assignments are being managed. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing the list of processed allocations. + public async Task>> AssigneProjectsToEmployeeAsync(List allocationsDto, Guid employeeId, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (allocationsDto == null || !allocationsDto.Any() || employeeId == Guid.Empty) + { + return ApiResponse>.ErrorResponse("Invalid details.", "A valid employee ID and a list of projects are required.", 400); + } + + _logger.LogInfo("Starting to manage {AllocationCount} project assignments for Employee {EmployeeId}.", allocationsDto.Count, employeeId); + + // --- (Placeholder) Security Check --- + // You MUST verify that the loggedInEmployee has permission to modify the assignments for the target employeeId. + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageTeam, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to manage assignments for employee {TargetEmployeeId}.", loggedInEmployee.Id, employeeId); + return ApiResponse>.ErrorResponse("Access Denied.", "You do not have permission to manage this employee's assignments.", 403); + } + + // --- Step 2: Fetch all relevant existing data in ONE database call --- + var projectIdsInDto = allocationsDto.Select(p => p.ProjectId).ToList(); + + // Fetch all currently active allocations for this employee for the projects in the request. + // We use a dictionary keyed by ProjectId for fast O(1) lookups inside the loop. + var existingActiveAllocations = await _context.ProjectAllocations + .Where(pa => pa.TenantId == tenantId && + pa.EmployeeId == employeeId && + projectIdsInDto.Contains(pa.ProjectId) && + pa.ReAllocationDate == null) // Only fetch active ones + .ToDictionaryAsync(pa => pa.ProjectId); + + var processedAllocations = new List(); + + // --- Step 3: Process all logic IN MEMORY, tracking changes --- + foreach (var dto in allocationsDto) + { + existingActiveAllocations.TryGetValue(dto.ProjectId, out var existingAllocation); + + if (dto.Status == false) // DEACTIVATE this project assignment + { + if (existingAllocation != null) + { + // Correct Update Pattern: Modify the fetched entity directly. + existingAllocation.ReAllocationDate = DateTime.UtcNow; // Use UTC for servers + existingAllocation.IsActive = false; + _context.ProjectAllocations.Update(existingAllocation); + processedAllocations.Add(existingAllocation); + } + // If it's not in our dictionary, it's already inactive. Do nothing. + } + else // ACTIVATE this project assignment + { + if (existingAllocation == null) + { + // Create a new allocation because an active one doesn't exist. + var newAllocation = _mapper.Map(dto); + newAllocation.EmployeeId = employeeId; + newAllocation.TenantId = tenantId; + newAllocation.AllocationDate = DateTime.UtcNow; + newAllocation.IsActive = true; + _context.ProjectAllocations.Add(newAllocation); + processedAllocations.Add(newAllocation); + } + // If it already exists in our dictionary, it's already active. Do nothing. + } + } + + try + { + // --- Step 4: Save all Adds and Updates in a SINGLE ATOMIC TRANSACTION --- + if (processedAllocations.Any()) + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully saved {ChangeCount} assignment changes for employee {EmployeeId}.", processedAllocations.Count, employeeId); + } + } + catch (DbUpdateException ex) + { + _logger.LogError(ex, "Failed to save assignment changes for employee {EmployeeId}.", employeeId); + return ApiResponse>.ErrorResponse("Database Error.", "An error occurred while saving the changes.", 500); + } + + // --- Step 5: Invalidate Cache ONCE after successful save --- + try + { + await _cache.ClearAllProjectIds(employeeId); + _logger.LogInfo("Successfully queued cache invalidation for employee {EmployeeId}.", employeeId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background cache invalidation failed for employee {EmployeeId}", employeeId); + } + + // --- Step 6: Map results using AutoMapper and return success --- + var resultVm = _mapper.Map>(processedAllocations); + return ApiResponse>.SuccessResponse(resultVm, "Assignments managed successfully.", 200); + } + #endregion #region =================================================================== Helper Functions =================================================================== diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index 2552444..bafa582 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -17,5 +17,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> GetEmployeeByProjectIdAsync(Guid? projectId, bool includeInactive, Guid tenantId, Employee loggedInEmployee); Task> GetProjectAllocationAsync(Guid? projectId, Guid tenantId, Employee loggedInEmployee); Task>> ManageAllocationAsync(List projectAllocationDots, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectsByEmployeeAsync(Guid employeeId, Guid tenantId, Employee loggedInEmployee); + Task>> AssigneProjectsToEmployeeAsync(List projectAllocationDtos, Guid employeeId, Guid tenantId, Employee loggedInEmployee); } } -- 2.43.0 From 57b7f941e61204fea4d7e85e7d1224c76f93ca67 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Wed, 16 Jul 2025 15:08:53 +0530 Subject: [PATCH 39/50] Optimized the manage task API in projectController --- Marco.Pms.CacheHelper/ProjectCache.cs | 33 +- .../{WorkItemDot.cs => WorkItemDto.cs} | 2 +- Marco.Pms.Model/Mapper/InfraMapper.cs | 2 +- .../Controllers/ProjectController.cs | 298 ++-------- .../Helpers/CacheUpdateHelper.cs | 17 +- Marco.Pms.Services/Helpers/GeneralHelper.cs | 214 +++++++ Marco.Pms.Services/Helpers/ProjectsHelper.cs | 4 +- .../MappingProfiles/MappingProfile.cs | 5 + Marco.Pms.Services/Program.cs | 1 + Marco.Pms.Services/Service/ProjectServices.cs | 547 +++++++++++++++++- .../ServiceInterfaces/IProjectServices.cs | 4 + 11 files changed, 826 insertions(+), 301 deletions(-) rename Marco.Pms.Model/Dtos/Projects/{WorkItemDot.cs => WorkItemDto.cs} (94%) create mode 100644 Marco.Pms.Services/Helpers/GeneralHelper.cs diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index c7d7e84..833e1a0 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -406,45 +406,22 @@ namespace Marco.Pms.CacheHelper return workItems; } - public async Task ManageWorkItemDetailsToCache(List workItems) + public async Task ManageWorkItemDetailsToCache(List workItems) { - var activityIds = workItems.Select(wi => wi.ActivityId).ToList(); - var workCategoryIds = workItems.Select(wi => wi.WorkCategoryId).ToList(); - var workItemIds = workItems.Select(wi => wi.Id).ToList(); - // fetching Activity master - var activities = await _context.ActivityMasters.Where(a => activityIds.Contains(a.Id)).ToListAsync() ?? new List(); - - // Fetching Work Category - var workCategories = await _context.WorkCategoryMasters.Where(wc => workCategoryIds.Contains(wc.Id)).ToListAsync() ?? new List(); - var task = await _context.TaskAllocations.Where(t => workItemIds.Contains(t.WorkItemId) && t.AssignmentDate == DateTime.UtcNow).ToListAsync(); - var todaysAssign = task.Sum(t => t.PlannedTask); - foreach (WorkItem workItem in workItems) + foreach (WorkItemMongoDB workItem in workItems) { - var activity = activities.FirstOrDefault(a => a.Id == workItem.ActivityId) ?? new ActivityMaster(); - var workCategory = workCategories.FirstOrDefault(a => a.Id == workItem.WorkCategoryId) ?? new WorkCategoryMaster(); - var filter = Builders.Filter.Eq(p => p.Id, workItem.Id.ToString()); var updates = Builders.Update.Combine( Builders.Update.Set(r => r.WorkAreaId, workItem.WorkAreaId.ToString()), Builders.Update.Set(r => r.ParentTaskId, (workItem.ParentTaskId != null ? workItem.ParentTaskId.ToString() : null)), Builders.Update.Set(r => r.PlannedWork, workItem.PlannedWork), - Builders.Update.Set(r => r.TodaysAssigned, todaysAssign), + Builders.Update.Set(r => r.TodaysAssigned, workItem.TodaysAssigned), Builders.Update.Set(r => r.CompletedWork, workItem.CompletedWork), Builders.Update.Set(r => r.Description, workItem.Description), Builders.Update.Set(r => r.TaskDate, workItem.TaskDate), Builders.Update.Set(r => r.ExpireAt, DateTime.UtcNow.Date.AddDays(1)), - Builders.Update.Set(r => r.ActivityMaster, new ActivityMasterMongoDB - { - Id = activity.Id.ToString(), - ActivityName = activity.ActivityName, - UnitOfMeasurement = activity.UnitOfMeasurement - }), - Builders.Update.Set(r => r.WorkCategoryMaster, new WorkCategoryMasterMongoDB - { - Id = workCategory.Id.ToString(), - Name = workCategory.Name, - Description = workCategory.Description, - }) + Builders.Update.Set(r => r.ActivityMaster, workItem.ActivityMaster), + Builders.Update.Set(r => r.WorkCategoryMaster, workItem.WorkCategoryMaster) ); var options = new UpdateOptions { IsUpsert = true }; var result = await _taskCollection.UpdateOneAsync(filter, updates, options); diff --git a/Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs b/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs similarity index 94% rename from Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs rename to Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs index e6ba436..7c98051 100644 --- a/Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs @@ -2,7 +2,7 @@ namespace Marco.Pms.Model.Dtos.Project { - public class WorkItemDot + public class WorkItemDto { [Key] public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Mapper/InfraMapper.cs b/Marco.Pms.Model/Mapper/InfraMapper.cs index 4ccb7c8..89097d1 100644 --- a/Marco.Pms.Model/Mapper/InfraMapper.cs +++ b/Marco.Pms.Model/Mapper/InfraMapper.cs @@ -48,7 +48,7 @@ namespace Marco.Pms.Model.Mapper } public static class WorkItemMapper { - public static WorkItem ToWorkItemFromWorkItemDto(this WorkItemDot model, Guid tenantId) + public static WorkItem ToWorkItemFromWorkItemDto(this WorkItemDto model, Guid tenantId) { return new WorkItem { diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 82ce0dd..a10fc66 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,9 +1,7 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; -using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Mapper; -using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Projects; @@ -325,188 +323,36 @@ namespace MarcoBMS.Services.Controllers [HttpGet("infra-details/{projectId}")] public async Task GetInfraDetails(Guid projectId) { - _logger.LogInfo("GetInfraDetails called for ProjectId: {ProjectId}", projectId); - - // Step 1: Get logged-in employee - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // Step 2: Check project-specific permission - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); - if (!hasProjectPermission) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - _logger.LogWarning("Project access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get Project Infrastructure by ProjectId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - // Step 3: Check 'ViewInfra' permission - var hasViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); - if (!hasViewInfraPermission) - { - _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have access to view infra", 403)); - } - var result = await _cache.GetBuildingInfra(projectId); - if (result == null) - { + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetInfraDetailsAsync(projectId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); - // Step 4: Fetch buildings for the project - var buildings = await _context.Buildings - .Where(b => b.ProjectId == projectId) - .ToListAsync(); - - var buildingIds = buildings.Select(b => b.Id).ToList(); - - // Step 5: Fetch floors associated with the buildings - var floors = await _context.Floor - .Where(f => buildingIds.Contains(f.BuildingId)) - .ToListAsync(); - - var floorIds = floors.Select(f => f.Id).ToList(); - - // Step 6: Fetch work areas associated with the floors - var workAreas = await _context.WorkAreas - .Where(wa => floorIds.Contains(wa.FloorId)) - .ToListAsync(); - var workAreaIds = workAreas.Select(wa => wa.Id).ToList(); - - // Step 7: Fetch work items associated with the work area - var workItems = await _context.WorkItems - .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) - .ToListAsync(); - - // Step 8: Build the infra hierarchy (Building > Floors > Work Areas) - List Buildings = new List(); - foreach (var building in buildings) - { - double buildingPlannedWorks = 0; - double buildingCompletedWorks = 0; - - var selectedFloors = floors.Where(f => f.BuildingId == building.Id).ToList(); - List Floors = new List(); - foreach (var floor in selectedFloors) - { - double floorPlannedWorks = 0; - double floorCompletedWorks = 0; - var selectedWorkAreas = workAreas.Where(wa => wa.FloorId == floor.Id).ToList(); - List WorkAreas = new List(); - foreach (var workArea in selectedWorkAreas) - { - double workAreaPlannedWorks = 0; - double workAreaCompletedWorks = 0; - var selectedWorkItems = workItems.Where(wi => wi.WorkAreaId == workArea.Id).ToList(); - foreach (var workItem in selectedWorkItems) - { - workAreaPlannedWorks += workItem.PlannedWork; - workAreaCompletedWorks += workItem.CompletedWork; - } - WorkAreaMongoDB workAreaMongo = new WorkAreaMongoDB - { - Id = workArea.Id.ToString(), - AreaName = workArea.AreaName, - PlannedWork = workAreaPlannedWorks, - CompletedWork = workAreaCompletedWorks - }; - WorkAreas.Add(workAreaMongo); - floorPlannedWorks += workAreaPlannedWorks; - floorCompletedWorks += workAreaCompletedWorks; - } - FloorMongoDB floorMongoDB = new FloorMongoDB - { - Id = floor.Id.ToString(), - FloorName = floor.FloorName, - PlannedWork = floorPlannedWorks, - CompletedWork = floorCompletedWorks, - WorkAreas = WorkAreas - }; - Floors.Add(floorMongoDB); - buildingPlannedWorks += floorPlannedWorks; - buildingCompletedWorks += floorCompletedWorks; - } - - var buildingMongo = new BuildingMongoDB - { - Id = building.Id.ToString(), - BuildingName = building.Name, - Description = building.Description, - PlannedWork = buildingPlannedWorks, - CompletedWork = buildingCompletedWorks, - Floors = Floors - }; - Buildings.Add(buildingMongo); - } - result = Buildings; - } - - _logger.LogInfo("Infra details fetched successfully for ProjectId: {ProjectId}, EmployeeId: {EmployeeId}, Buildings: {Count}", - projectId, loggedInEmployee.Id, result.Count); - - return Ok(ApiResponse.SuccessResponse(result, "Infra details fetched successfully", 200)); } [HttpGet("tasks/{workAreaId}")] public async Task GetWorkItems(Guid workAreaId) { - _logger.LogInfo("GetWorkItems called for WorkAreaId: {WorkAreaId}", workAreaId); - - // Step 1: Get the currently logged-in employee - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // Step 2: Check if the employee has ViewInfra permission - var hasViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); - if (!hasViewInfraPermission) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have permission to view infrastructure", 403)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get Work Items by WorkAreaId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - // Step 3: Check if the specified Work Area exists - var isWorkAreaExist = await _context.WorkAreas.AnyAsync(wa => wa.Id == workAreaId); - if (!isWorkAreaExist) - { - _logger.LogWarning("Work Area not found for WorkAreaId: {WorkAreaId}", workAreaId); - return NotFound(ApiResponse.ErrorResponse("Work Area not found", "Work Area not found in database", 404)); - } - - // Step 4: Fetch WorkItems with related Activity and Work Category data - var workItemVMs = await _cache.GetWorkItemDetailsByWorkArea(workAreaId); - if (workItemVMs == null) - { - var workItems = await _context.WorkItems - .Include(wi => wi.ActivityMaster) - .Include(wi => wi.WorkCategoryMaster) - .Where(wi => wi.WorkAreaId == workAreaId) - .ToListAsync(); - - workItemVMs = workItems.Select(wi => new WorkItemMongoDB - { - Id = wi.Id.ToString(), - WorkAreaId = wi.WorkAreaId.ToString(), - ParentTaskId = wi.ParentTaskId.ToString(), - ActivityMaster = new ActivityMasterMongoDB - { - Id = wi.ActivityId.ToString(), - ActivityName = wi.ActivityMaster != null ? wi.ActivityMaster.ActivityName : null, - UnitOfMeasurement = wi.ActivityMaster != null ? wi.ActivityMaster.UnitOfMeasurement : null - }, - WorkCategoryMaster = new WorkCategoryMasterMongoDB - { - Id = wi.WorkCategoryId.ToString() ?? "", - Name = wi.WorkCategoryMaster != null ? wi.WorkCategoryMaster.Name : "", - Description = wi.WorkCategoryMaster != null ? wi.WorkCategoryMaster.Description : "" - }, - PlannedWork = wi.PlannedWork, - CompletedWork = wi.CompletedWork, - Description = wi.Description, - TaskDate = wi.TaskDate, - }).ToList(); - - await _cache.ManageWorkItemDetails(workItems); - } - - _logger.LogInfo("{Count} work items fetched successfully for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); - - // Step 5: Return result - return Ok(ApiResponse.SuccessResponse(workItemVMs, $"{workItemVMs.Count} records of tasks fetched successfully", 200)); + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetWorkItemsAsync(workAreaId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } #endregion @@ -514,107 +360,29 @@ namespace MarcoBMS.Services.Controllers #region =================================================================== Project Infrastructre Manage APIs =================================================================== [HttpPost("task")] - public async Task CreateProjectTask(List workItemDtos) + public async Task CreateProjectTask([FromBody] List workItemDtos) { - _logger.LogInfo("CreateProjectTask called with {Count} items", workItemDtos?.Count ?? 0); - - // Validate request - if (workItemDtos == null || !workItemDtos.Any()) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - _logger.LogWarning("No work items provided in the request."); - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Work Item details are not valid.", 400)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - var workItemsToCreate = new List(); - var workItemsToUpdate = new List(); - var responseList = new List(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - string message = ""; - List workAreaIds = new List(); - var workItemIds = workItemDtos.Where(wi => wi.Id != null && wi.Id != Guid.Empty).Select(wi => wi.Id).ToList(); - var workItems = await _context.WorkItems.AsNoTracking().Where(wi => workItemIds.Contains(wi.Id)).ToListAsync(); - - foreach (var itemDto in workItemDtos) + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.CreateProjectTaskAsync(workItemDtos, tenantId, loggedInEmployee); + if (response.Success) { - var workItem = itemDto.ToWorkItemFromWorkItemDto(tenantId); - var workArea = await _context.WorkAreas.Include(a => a.Floor).FirstOrDefaultAsync(a => a.Id == workItem.WorkAreaId) ?? new WorkArea(); - - Building building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == (workArea.Floor != null ? workArea.Floor.BuildingId : Guid.Empty)) ?? new Building(); - - if (itemDto.Id != null && itemDto.Id != Guid.Empty) - { - // Update existing - workItemsToUpdate.Add(workItem); - message = $"Task Updated in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; - var existingWorkItem = workItems.FirstOrDefault(wi => wi.Id == workItem.Id); - double plannedWork = 0; - double completedWork = 0; - if (existingWorkItem != null) - { - if (existingWorkItem.PlannedWork != workItem.PlannedWork && existingWorkItem.CompletedWork != workItem.CompletedWork) - { - plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; - completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; - } - else if (existingWorkItem.PlannedWork == workItem.PlannedWork && existingWorkItem.CompletedWork != workItem.CompletedWork) - { - plannedWork = 0; - completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; - } - else if (existingWorkItem.PlannedWork != workItem.PlannedWork && existingWorkItem.CompletedWork == workItem.CompletedWork) - { - plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; - completedWork = 0; - } - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, plannedWork, completedWork); - } - } - else - { - // Create new - workItem.Id = Guid.NewGuid(); - workItemsToCreate.Add(workItem); - message = $"Task Added in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, workItem.PlannedWork, workItem.CompletedWork); - } - - responseList.Add(new WorkItemVM - { - WorkItemId = workItem.Id, - WorkItem = workItem - }); - workAreaIds.Add(workItem.WorkAreaId); + List workAreaIds = response.Data.Select(pa => pa.WorkItem?.WorkAreaId ?? Guid.Empty).ToList(); + string message = response.Message; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = message }; + await _signalR.SendNotificationAsync(notification); } - string responseMessage = ""; - // Apply DB changes - if (workItemsToCreate.Any()) - { - _logger.LogInfo("Adding {Count} new work items", workItemsToCreate.Count); - await _context.WorkItems.AddRangeAsync(workItemsToCreate); - responseMessage = "Task Added Successfully"; - await _cache.ManageWorkItemDetails(workItemsToCreate); - } + return StatusCode(response.StatusCode, response); - if (workItemsToUpdate.Any()) - { - _logger.LogInfo("Updating {Count} existing work items", workItemsToUpdate.Count); - _context.WorkItems.UpdateRange(workItemsToUpdate); - responseMessage = "Task Updated Successfully"; - await _cache.ManageWorkItemDetails(workItemsToUpdate); - } - - await _context.SaveChangesAsync(); - - _logger.LogInfo("CreateProjectTask completed successfully. Created: {Created}, Updated: {Updated}", workItemsToCreate.Count, workItemsToUpdate.Count); - - - - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = message }; - - await _signalR.SendNotificationAsync(notification); - - return Ok(ApiResponse.SuccessResponse(responseList, responseMessage, 200)); } [HttpDelete("task/{id}")] diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index aca439b..9a01b83 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -17,9 +17,10 @@ namespace Marco.Pms.Services.Helpers private readonly ILoggingService _logger; private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; + private readonly GeneralHelper _generalHelper; public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ReportCache reportCache, ILoggingService logger, - IDbContextFactory dbContextFactory, ApplicationDbContext context) + IDbContextFactory dbContextFactory, ApplicationDbContext context, GeneralHelper generalHelper) { _projectCache = projectCache; _employeeCache = employeeCache; @@ -27,6 +28,7 @@ namespace Marco.Pms.Services.Helpers _logger = logger; _dbContextFactory = dbContextFactory; _context = context; + _generalHelper = generalHelper; } // ------------------------------------ Project Details Cache --------------------------------------- @@ -563,6 +565,19 @@ namespace Marco.Pms.Services.Helpers } } public async Task ManageWorkItemDetails(List workItems) + { + try + { + var workAreaId = workItems.First().WorkAreaId; + var workItemDB = await _generalHelper.GetWorkItemsListFromDB(workAreaId); + await _projectCache.ManageWorkItemDetailsToCache(workItemDB); + } + catch (Exception ex) + { + _logger.LogWarning("Error occured while saving workItems form Cache: {Error}", ex.Message); + } + } + public async Task ManageWorkItemDetailsByVM(List workItems) { try { diff --git a/Marco.Pms.Services/Helpers/GeneralHelper.cs b/Marco.Pms.Services/Helpers/GeneralHelper.cs new file mode 100644 index 0000000..c2f8fe4 --- /dev/null +++ b/Marco.Pms.Services/Helpers/GeneralHelper.cs @@ -0,0 +1,214 @@ +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.MongoDBModels; +using MarcoBMS.Services.Service; +using Microsoft.EntityFrameworkCore; + +namespace Marco.Pms.Services.Helpers +{ + public class GeneralHelper + { + private readonly IDbContextFactory _dbContextFactory; + private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate + private readonly ILoggingService _logger; + public GeneralHelper(IDbContextFactory dbContextFactory, + ApplicationDbContext context, + ILoggingService logger) + { + _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + public async Task> GetProjectInfraFromDB(Guid projectId) + { + // Each task uses its own DbContext instance for thread safety. Projections are used for efficiency. + + // Task to fetch Buildings, Floors, and WorkAreas using projections + var hierarchyTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + var buildings = await context.Buildings.AsNoTracking().Where(b => b.ProjectId == projectId).Select(b => new { b.Id, b.Name, b.Description }).ToListAsync(); + var buildingIds = buildings.Select(b => b.Id).ToList(); + var floors = await context.Floor.AsNoTracking().Where(f => buildingIds.Contains(f.BuildingId)).Select(f => new { f.Id, f.BuildingId, f.FloorName }).ToListAsync(); + var floorIds = floors.Select(f => f.Id).ToList(); + var workAreas = await context.WorkAreas.AsNoTracking().Where(wa => floorIds.Contains(wa.FloorId)).Select(wa => new { wa.Id, wa.FloorId, wa.AreaName }).ToListAsync(); + return (buildings, floors, workAreas); + }); + + // Task to get work summaries, AGGREGATED ON THE DATABASE SERVER + var workSummaryTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + // This is the most powerful optimization. It avoids pulling all WorkItem rows. + return await context.WorkItems.AsNoTracking() + .Where(wi => wi.WorkArea != null && wi.WorkArea.Floor != null && wi.WorkArea.Floor.Building != null && wi.WorkArea.Floor.Building.ProjectId == projectId) + .GroupBy(wi => wi.WorkAreaId) // Group by the parent WorkArea + .Select(g => new + { + WorkAreaId = g.Key, + PlannedWork = g.Sum(i => i.PlannedWork), + CompletedWork = g.Sum(i => i.CompletedWork) + }) + .ToDictionaryAsync(x => x.WorkAreaId); // Return a ready-to-use dictionary for fast lookups + }); + + await Task.WhenAll(hierarchyTask, workSummaryTask); + + var (buildings, floors, workAreas) = await hierarchyTask; + var workSummariesByWorkAreaId = await workSummaryTask; + + // --- Step 4: Build the hierarchy efficiently using Lookups --- + // Using lookups is much faster (O(1)) than repeated .Where() calls (O(n)). + var floorsByBuildingId = floors.ToLookup(f => f.BuildingId); + var workAreasByFloorId = workAreas.ToLookup(wa => wa.FloorId); + + var buildingMongoList = new List(); + foreach (var building in buildings) + { + double buildingPlanned = 0, buildingCompleted = 0; + var floorMongoList = new List(); + + foreach (var floor in floorsByBuildingId[building.Id]) // Fast lookup + { + double floorPlanned = 0, floorCompleted = 0; + var workAreaMongoList = new List(); + + foreach (var workArea in workAreasByFloorId[floor.Id]) // Fast lookup + { + // Get the pre-calculated summary from the dictionary. O(1) operation. + workSummariesByWorkAreaId.TryGetValue(workArea.Id, out var summary); + var waPlanned = summary?.PlannedWork ?? 0; + var waCompleted = summary?.CompletedWork ?? 0; + + workAreaMongoList.Add(new WorkAreaMongoDB + { + Id = workArea.Id.ToString(), + AreaName = workArea.AreaName, + PlannedWork = waPlanned, + CompletedWork = waCompleted + }); + + floorPlanned += waPlanned; + floorCompleted += waCompleted; + } + + floorMongoList.Add(new FloorMongoDB + { + Id = floor.Id.ToString(), + FloorName = floor.FloorName, + PlannedWork = floorPlanned, + CompletedWork = floorCompleted, + WorkAreas = workAreaMongoList + }); + + buildingPlanned += floorPlanned; + buildingCompleted += floorCompleted; + } + + buildingMongoList.Add(new BuildingMongoDB + { + Id = building.Id.ToString(), + BuildingName = building.Name, + Description = building.Description, + PlannedWork = buildingPlanned, + CompletedWork = buildingCompleted, + Floors = floorMongoList + }); + } + return buildingMongoList; + } + + /// + /// Retrieves a list of work items for a specific work area, including a summary of tasks assigned for the current day. + /// This method is highly optimized to run database operations in parallel and perform aggregations on the server. + /// + /// The ID of the work area. + /// A list of WorkItemMongoDB objects with calculated daily assignments. + public async Task> GetWorkItemsListFromDB(Guid workAreaId) + { + _logger.LogInfo("Fetching DB work items for WorkAreaId: {WorkAreaId}", workAreaId); + + try + { + // --- Step 1: Run independent database queries in PARALLEL --- + // We can fetch the WorkItems and the aggregated TaskAllocations at the same time. + + // Task 1: Fetch the WorkItem entities and their related data. + var workItemsTask = _context.WorkItems + .Include(wi => wi.ActivityMaster) + .Include(wi => wi.WorkCategoryMaster) + .Where(wi => wi.WorkAreaId == workAreaId) + .AsNoTracking() + .ToListAsync(); + + // Task 2: Fetch and AGGREGATE today's task allocations ON THE DATABASE SERVER. + var todaysAssignmentsTask = Task.Run(async () => + { + // Correctly define "today's" date range to avoid precision issues. + var today = DateTime.UtcNow.Date; + var tomorrow = today.AddDays(1); + + using var context = _dbContextFactory.CreateDbContext(); // Use a factory for thread safety + + // This is the most powerful optimization: + // 1. It filters by WorkAreaId directly, making it independent of the first query. + // 2. It filters by a correct date range. + // 3. It groups and sums on the DB server, returning only a small summary. + return await context.TaskAllocations + .Where(t => t.WorkItem != null && t.WorkItem.WorkAreaId == workAreaId && + t.AssignmentDate >= today && t.AssignmentDate < tomorrow) + .GroupBy(t => t.WorkItemId) + .Select(g => new + { + WorkItemId = g.Key, + TodaysAssigned = g.Sum(x => x.PlannedTask) + }) + // Return a dictionary for instant O(1) lookups later. + .ToDictionaryAsync(x => x.WorkItemId, x => x.TodaysAssigned); + }); + + // Await both parallel database operations to complete. + await Task.WhenAll(workItemsTask, todaysAssignmentsTask); + + // Retrieve the results from the completed tasks. + var workItemsFromDb = await workItemsTask; + var todaysAssignments = await todaysAssignmentsTask; + + // --- Step 2: Map to the ViewModel/MongoDB model efficiently --- + var workItemVMs = workItemsFromDb.Select(wi => new WorkItemMongoDB + { + Id = wi.Id.ToString(), + WorkAreaId = wi.WorkAreaId.ToString(), + ParentTaskId = wi.ParentTaskId.ToString(), + ActivityMaster = wi.ActivityMaster != null ? new ActivityMasterMongoDB + { + Id = wi.ActivityMaster.Id.ToString(), + ActivityName = wi.ActivityMaster.ActivityName, + UnitOfMeasurement = wi.ActivityMaster.UnitOfMeasurement + } : null, + WorkCategoryMaster = wi.WorkCategoryMaster != null ? new WorkCategoryMasterMongoDB + { + Id = wi.WorkCategoryMaster.Id.ToString(), + Name = wi.WorkCategoryMaster.Name, + Description = wi.WorkCategoryMaster.Description + } : null, + PlannedWork = wi.PlannedWork, + CompletedWork = wi.CompletedWork, + Description = wi.Description, + TaskDate = wi.TaskDate, + // Use the fast dictionary lookup instead of the slow in-memory Where/Sum. + TodaysAssigned = todaysAssignments.GetValueOrDefault(wi.Id, 0) + }).ToList(); + + _logger.LogInfo("Successfully processed {WorkItemCount} work items for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); + + return workItemVMs; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching DB work items for WorkAreaId: {WorkAreaId}", workAreaId); + // Return an empty list or re-throw, depending on your application's error handling strategy. + return new List(); + } + } + } +} diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs index fe70a0a..e7e1dd6 100644 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ b/Marco.Pms.Services/Helpers/ProjectsHelper.cs @@ -11,14 +11,12 @@ namespace MarcoBMS.Services.Helpers public class ProjectsHelper { private readonly ApplicationDbContext _context; - private readonly RolesHelper _rolesHelper; private readonly CacheUpdateHelper _cache; private readonly PermissionServices _permission; - public ProjectsHelper(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache, PermissionServices permission) + public ProjectsHelper(ApplicationDbContext context, CacheUpdateHelper cache, PermissionServices permission) { _context = context; - _rolesHelper = rolesHelper; _cache = cache; _permission = permission; } diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index ea42d16..50d2ea9 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -50,6 +50,11 @@ namespace Marco.Pms.Services.MappingProfiles opt => opt.MapFrom(src => src.EmpID)); CreateMap(); CreateMap(); + + CreateMap() + .ForMember( + dest => dest.Description, + opt => opt.MapFrom(src => src.Comment)); #endregion #region ======================================================= Projects ======================================================= diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 26d8eba..3c73416 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -163,6 +163,7 @@ builder.Services.AddScoped(); #endregion #region Helpers +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 9024112..6d811fc 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -29,6 +29,7 @@ namespace Marco.Pms.Services.Service private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; private readonly IMapper _mapper; + private readonly GeneralHelper _generalHelper; public ProjectServices( IDbContextFactory dbContextFactory, ApplicationDbContext context, @@ -36,7 +37,8 @@ namespace Marco.Pms.Services.Service ProjectsHelper projectsHelper, PermissionServices permission, CacheUpdateHelper cache, - IMapper mapper) + IMapper mapper, + GeneralHelper generalHelper) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); _context = context ?? throw new ArgumentNullException(nameof(context)); @@ -45,6 +47,7 @@ namespace Marco.Pms.Services.Service _permission = permission ?? throw new ArgumentNullException(nameof(permission)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + _generalHelper = generalHelper ?? throw new ArgumentNullException(nameof(generalHelper)); } #region =================================================================== Project Get APIs =================================================================== @@ -898,6 +901,525 @@ namespace Marco.Pms.Services.Service #endregion + #region =================================================================== Project InfraStructure Get APIs =================================================================== + + /// + /// Retrieves the full infrastructure hierarchy (Buildings, Floors, Work Areas) for a project, + /// including aggregated work summaries. + /// + public async Task> GetInfraDetailsAsync(Guid projectId, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("GetInfraDetails called for ProjectId: {ProjectId}", projectId); + + try + { + // --- Step 1: Run independent permission checks in PARALLEL --- + var projectPermissionTask = _permission.HasProjectPermission(loggedInEmployee, projectId); + var viewInfraPermissionTask = _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); + + await Task.WhenAll(projectPermissionTask, viewInfraPermissionTask); + + if (!await projectPermissionTask) + { + _logger.LogWarning("Project access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); + return ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403); + } + if (!await viewInfraPermissionTask) + { + _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Access denied", "You don't have access to view this project's infrastructure", 403); + } + + // --- Step 2: Cache-First Strategy --- + var cachedResult = await _cache.GetBuildingInfra(projectId); + if (cachedResult != null) + { + _logger.LogInfo("Cache HIT for infra details for ProjectId: {ProjectId}", projectId); + return ApiResponse.SuccessResponse(cachedResult, "Infra details fetched successfully from cache.", 200); + } + + _logger.LogInfo("Cache MISS for infra details for ProjectId: {ProjectId}. Fetching from database.", projectId); + + // --- Step 3: Fetch all required data from the database --- + + var buildingMongoList = await _generalHelper.GetProjectInfraFromDB(projectId); + // --- Step 5: Proactively update the cache --- + //await _cache.SetBuildingInfra(projectId, buildingMongoList); + + _logger.LogInfo("Infra details fetched successfully for ProjectId: {ProjectId}, Buildings: {Count}", projectId, buildingMongoList.Count); + return ApiResponse.SuccessResponse(buildingMongoList, "Infra details fetched successfully", 200); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching infra details for ProjectId: {ProjectId}", projectId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "An error occurred while processing your request.", 500); + } + } + + /// + /// Retrieves a list of work items for a specific work area, ensuring the user has appropriate permissions. + /// + /// The ID of the work area. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing a list of work items or an error. + public async Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("GetWorkItems called for WorkAreaId: {WorkAreaId} by User: {UserId}", workAreaId, loggedInEmployee.Id); + + try + { + // --- Step 1: Cache-First Strategy --- + var cachedWorkItems = await _cache.GetWorkItemDetailsByWorkArea(workAreaId); + if (cachedWorkItems != null) + { + _logger.LogInfo("Cache HIT for WorkAreaId: {WorkAreaId}. Returning {Count} items from cache.", workAreaId, cachedWorkItems.Count); + return ApiResponse.SuccessResponse(cachedWorkItems, $"{cachedWorkItems.Count} tasks retrieved successfully from cache.", 200); + } + + _logger.LogInfo("Cache MISS for WorkAreaId: {WorkAreaId}. Fetching from database.", workAreaId); + + // --- Step 2: Security Check First --- + // This pattern remains the most robust: verify permissions before fetching a large list. + var projectInfo = await _context.WorkAreas + .Where(wa => wa.Id == workAreaId && wa.TenantId == tenantId && wa.Floor != null && wa.Floor.Building != null) + .Select(wa => new { wa.Floor!.Building!.ProjectId }) + .FirstOrDefaultAsync(); + + if (projectInfo == null) + { + _logger.LogWarning("Work Area not found for WorkAreaId: {WorkAreaId}", workAreaId); + return ApiResponse.ErrorResponse("Not Found", $"Work Area with ID {workAreaId} not found.", 404); + } + + var hasProjectAccess = await _permission.HasProjectPermission(loggedInEmployee, projectInfo.ProjectId); + var hasGenericViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); + + if (!hasProjectAccess || !hasGenericViewInfraPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} on WorkAreaId {WorkAreaId}.", loggedInEmployee.Id, workAreaId); + return ApiResponse.ErrorResponse("Access Denied", "You do not have sufficient permissions to view these work items.", 403); + } + + // --- Step 3: Fetch Full Entities for Caching and Mapping --- + var workItemVMs = await _generalHelper.GetWorkItemsListFromDB(workAreaId); + + // --- Step 5: Proactively Update the Cache with the Correct Object Type --- + // We now pass the 'workItemsFromDb' list, which is the required List. + + try + { + await _cache.ManageWorkItemDetailsByVM(workItemVMs); + _logger.LogInfo("Successfully queued cache update for WorkAreaId: {WorkAreaId}", workAreaId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background cache update failed for WorkAreaId: {WorkAreaId}", workAreaId); + } + + + _logger.LogInfo("{Count} work items fetched successfully for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); + return ApiResponse.SuccessResponse(workItemVMs, $"{workItemVMs.Count} tasks fetched successfully.", 200); + } + catch (Exception ex) + { + // --- Step 6: Graceful Error Handling --- + _logger.LogError(ex, "An unexpected error occurred while getting work items for WorkAreaId: {WorkAreaId}", workAreaId); + return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); + } + } + + #endregion + + #region =================================================================== Project Infrastructre Manage APIs =================================================================== + + public async Task> CreateProjectTask1(List workItemDtos, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("CreateProjectTask called with {Count} items", workItemDtos?.Count ?? 0); + + // Validate request + if (workItemDtos == null || !workItemDtos.Any()) + { + _logger.LogWarning("No work items provided in the request."); + return ApiResponse.ErrorResponse("Invalid details.", "Work Item details are not valid.", 400); + } + + var workItemsToCreate = new List(); + var workItemsToUpdate = new List(); + var responseList = new List(); + string message = ""; + List workAreaIds = new List(); + var workItemIds = workItemDtos.Where(wi => wi.Id != null && wi.Id != Guid.Empty).Select(wi => wi.Id).ToList(); + var workItems = await _context.WorkItems.AsNoTracking().Where(wi => workItemIds.Contains(wi.Id)).ToListAsync(); + + foreach (var itemDto in workItemDtos) + { + var workItem = _mapper.Map(itemDto); + workItem.TenantId = tenantId; + var workArea = await _context.WorkAreas.Include(a => a.Floor).FirstOrDefaultAsync(a => a.Id == workItem.WorkAreaId) ?? new WorkArea(); + + Building building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == (workArea.Floor != null ? workArea.Floor.BuildingId : Guid.Empty)) ?? new Building(); + + if (itemDto.Id != null && itemDto.Id != Guid.Empty) + { + // Update existing + workItemsToUpdate.Add(workItem); + message = $"Task Updated in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + var existingWorkItem = workItems.FirstOrDefault(wi => wi.Id == workItem.Id); + if (existingWorkItem != null) + { + double plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; + double completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; + await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, plannedWork, completedWork); + } + } + else + { + // Create new + workItem.Id = Guid.NewGuid(); + workItemsToCreate.Add(workItem); + message = $"Task Added in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, workItem.PlannedWork, workItem.CompletedWork); + } + + responseList.Add(new WorkItemVM + { + WorkItemId = workItem.Id, + WorkItem = workItem + }); + workAreaIds.Add(workItem.WorkAreaId); + + } + // Apply DB changes + if (workItemsToCreate.Any()) + { + _logger.LogInfo("Adding {Count} new work items", workItemsToCreate.Count); + await _context.WorkItems.AddRangeAsync(workItemsToCreate); + await _cache.ManageWorkItemDetails(workItemsToCreate); + } + + if (workItemsToUpdate.Any()) + { + _logger.LogInfo("Updating {Count} existing work items", workItemsToUpdate.Count); + _context.WorkItems.UpdateRange(workItemsToUpdate); + await _cache.ManageWorkItemDetails(workItemsToUpdate); + } + + await _context.SaveChangesAsync(); + + _logger.LogInfo("CreateProjectTask completed successfully. Created: {Created}, Updated: {Updated}", workItemsToCreate.Count, workItemsToUpdate.Count); + + return ApiResponse.SuccessResponse(responseList, message, 200); + } + + /// + /// Creates or updates a batch of work items. + /// This method is optimized to perform all database operations in a single, atomic transaction. + /// + public async Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("CreateProjectTask called with {Count} items by user {UserId}", workItemDtos?.Count ?? 0, loggedInEmployee.Id); + + // --- Step 1: Input Validation --- + if (workItemDtos == null || !workItemDtos.Any()) + { + _logger.LogWarning("No work items provided in the request."); + return ApiResponse>.ErrorResponse("Invalid details.", "Work Item details list cannot be empty.", 400); + } + + // --- Step 2: Fetch all required existing data in bulk --- + var workAreaIds = workItemDtos.Select(d => d.WorkAreaID).Distinct().ToList(); + var workItemIdsToUpdate = workItemDtos.Where(d => d.Id.HasValue).Select(d => d.Id!.Value).ToList(); + + // Fetch all relevant WorkAreas and their parent hierarchy in ONE query + var workAreasFromDb = await _context.WorkAreas + .Where(wa => wa.Floor != null && wa.Floor.Building != null && workAreaIds.Contains(wa.Id) && wa.TenantId == tenantId) + .Include(wa => wa.Floor!.Building) // Eagerly load the entire path + .ToDictionaryAsync(wa => wa.Id); // Dictionary for fast lookups + + // Fetch all existing WorkItems that need updating in ONE query + var existingWorkItemsToUpdate = await _context.WorkItems + .Where(wi => workItemIdsToUpdate.Contains(wi.Id) && wi.TenantId == tenantId) + .ToDictionaryAsync(wi => wi.Id); // Dictionary for fast lookups + + // --- (Placeholder) Security Check --- + // You MUST verify the user has permission to modify ALL WorkAreas in the batch. + var projectIdsInBatch = workAreasFromDb.Values.Select(wa => wa.Floor!.Building!.ProjectId).Distinct(); + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProjectInfra, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to create/update tasks.", loggedInEmployee.Id); + return ApiResponse>.ErrorResponse("Access Denied.", "You do not have permission to modify tasks in one or more of the specified work areas.", 403); + } + + var workItemsToCreate = new List(); + var workItemsToModify = new List(); + var workDeltaForCache = new Dictionary(); // WorkAreaId -> (Delta) + string message = ""; + + // --- Step 3: Process all logic IN MEMORY, tracking changes --- + foreach (var dto in workItemDtos) + { + if (!workAreasFromDb.TryGetValue(dto.WorkAreaID, out var workArea)) + { + _logger.LogWarning("Skipping item because WorkAreaId {WorkAreaId} was not found or is invalid.", dto.WorkAreaID); + continue; // Skip this item as its parent WorkArea is invalid + } + + if (dto.Id.HasValue && existingWorkItemsToUpdate.TryGetValue(dto.Id.Value, out var existingWorkItem)) + { + // --- UPDATE Logic --- + var plannedDelta = dto.PlannedWork - existingWorkItem.PlannedWork; + var completedDelta = dto.CompletedWork - existingWorkItem.CompletedWork; + + // Apply changes from DTO to the fetched entity to prevent data loss + _mapper.Map(dto, existingWorkItem); + workItemsToModify.Add(existingWorkItem); + + // Track the change in work for cache update + workDeltaForCache[workArea.Id] = ( + workDeltaForCache.GetValueOrDefault(workArea.Id).Planned + plannedDelta, + workDeltaForCache.GetValueOrDefault(workArea.Id).Completed + completedDelta + ); + message = $"Task Updated in Building: {workArea.Floor?.Building?.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + } + else + { + // --- CREATE Logic --- + var newWorkItem = _mapper.Map(dto); + newWorkItem.Id = Guid.NewGuid(); // Ensure new GUID is set + newWorkItem.TenantId = tenantId; + workItemsToCreate.Add(newWorkItem); + + // Track the change in work for cache update + workDeltaForCache[workArea.Id] = ( + workDeltaForCache.GetValueOrDefault(workArea.Id).Planned + newWorkItem.PlannedWork, + workDeltaForCache.GetValueOrDefault(workArea.Id).Completed + newWorkItem.CompletedWork + ); + message = $"Task Added in Building: {workArea.Floor?.Building?.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + } + } + + try + { + // --- Step 4: Save all database changes in a SINGLE TRANSACTION --- + if (workItemsToCreate.Any()) _context.WorkItems.AddRange(workItemsToCreate); + if (workItemsToModify.Any()) _context.WorkItems.UpdateRange(workItemsToModify); // EF Core handles individual updates correctly here + + if (workItemsToCreate.Any() || workItemsToModify.Any()) + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully saved {CreatedCount} new and {UpdatedCount} updated work items.", workItemsToCreate.Count, workItemsToModify.Count); + + // --- Step 5: Update Cache and SignalR AFTER successful DB save (non-blocking) --- + var allAffectedItems = workItemsToCreate.Concat(workItemsToModify).ToList(); + _ = Task.Run(async () => + { + await UpdateCacheAndNotify(workDeltaForCache, allAffectedItems); + }); + } + } + catch (DbUpdateException ex) + { + _logger.LogError(ex, "A database error occurred while creating/updating tasks."); + return ApiResponse>.ErrorResponse("Database Error", "Failed to save changes.", 500); + } + + // --- Step 6: Prepare and return the response --- + var allProcessedItems = workItemsToCreate.Concat(workItemsToModify).ToList(); + var responseList = allProcessedItems.Select(wi => new WorkItemVM + { + WorkItemId = wi.Id, + WorkItem = wi + }).ToList(); + + + return ApiResponse>.SuccessResponse(responseList, message, 200); + } + + + //public async Task DeleteProjectTask(Guid id) + //{ + // var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + // List workAreaIds = new List(); + // WorkItem? task = await _context.WorkItems.AsNoTracking().Include(t => t.WorkArea).FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); + // if (task != null) + // { + // if (task.CompletedWork == 0) + // { + // var assignedTask = await _context.TaskAllocations.Where(t => t.WorkItemId == id).ToListAsync(); + // if (assignedTask.Count == 0) + // { + // _context.WorkItems.Remove(task); + // await _context.SaveChangesAsync(); + // _logger.LogInfo("Task with ID {WorkItemId} has been successfully deleted.", task.Id); + + // var floorId = task.WorkArea?.FloorId; + // var floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == floorId); + + + // workAreaIds.Add(task.WorkAreaId); + // var projectId = floor?.Building?.ProjectId; + + // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = $"Task Deleted in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}, in Area: {task.WorkArea?.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}" }; + // await _signalR.SendNotificationAsync(notification); + // await _cache.DeleteWorkItemByIdAsync(task.Id); + // if (projectId != null) + // { + // await _cache.DeleteProjectByIdAsync(projectId.Value); + // } + // } + // else + // { + // _logger.LogWarning("Task with ID {WorkItemId} is currently assigned and cannot be deleted.", task.Id); + // return BadRequest(ApiResponse.ErrorResponse("Task is currently assigned and cannot be deleted.", "Task is currently assigned and cannot be deleted.", 400)); + // } + // } + // else + // { + // double percentage = (task.CompletedWork / task.PlannedWork) * 100; + // percentage = Math.Round(percentage, 2); + // _logger.LogWarning("Task with ID {WorkItemId} is {CompletionPercentage}% complete and cannot be deleted", task.Id, percentage); + // return BadRequest(ApiResponse.ErrorResponse(System.String.Format("Task is {0}% complete and cannot be deleted", percentage), System.String.Format("Task is {0}% complete and cannot be deleted", percentage), 400)); + + // } + // } + // else + // { + // _logger.LogWarning("Task with ID {WorkItemId} not found ID database", id); + // } + // return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); + //} + + //public async Task ManageProjectInfra(List infraDots) + //{ + // var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // var responseData = new InfraVM { }; + // string responseMessage = ""; + // string message = ""; + // List projectIds = new List(); + // if (infraDots != null) + // { + // foreach (var item in infraDots) + // { + // if (item.Building != null) + // { + + // Building building = item.Building.ToBuildingFromBuildingDto(tenantId); + // building.TenantId = tenantId; + + // if (item.Building.Id == null) + // { + // //create + // _context.Buildings.Add(building); + // await _context.SaveChangesAsync(); + // responseData.building = building; + // responseMessage = "Buliding Added Successfully"; + // message = "Building Added"; + // await _cache.AddBuildngInfra(building.ProjectId, building); + // } + // else + // { + // //update + // _context.Buildings.Update(building); + // await _context.SaveChangesAsync(); + // responseData.building = building; + // responseMessage = "Buliding Updated Successfully"; + // message = "Building Updated"; + // await _cache.UpdateBuildngInfra(building.ProjectId, building); + // } + // projectIds.Add(building.ProjectId); + // } + // if (item.Floor != null) + // { + // Floor floor = item.Floor.ToFloorFromFloorDto(tenantId); + // floor.TenantId = tenantId; + // bool isCreated = false; + + // if (item.Floor.Id == null) + // { + // //create + // _context.Floor.Add(floor); + // await _context.SaveChangesAsync(); + // responseData.floor = floor; + // responseMessage = "Floor Added Successfully"; + // message = "Floor Added"; + // isCreated = true; + // } + // else + // { + // //update + // _context.Floor.Update(floor); + // await _context.SaveChangesAsync(); + // responseData.floor = floor; + // responseMessage = "Floor Updated Successfully"; + // message = "Floor Updated"; + // } + // Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); + // var projectId = building?.ProjectId ?? Guid.Empty; + // projectIds.Add(projectId); + // message = $"{message} in Building: {building?.Name}"; + // if (isCreated) + // { + // await _cache.AddBuildngInfra(projectId, floor: floor); + // } + // else + // { + // await _cache.UpdateBuildngInfra(projectId, floor: floor); + // } + // } + // if (item.WorkArea != null) + // { + // WorkArea workArea = item.WorkArea.ToWorkAreaFromWorkAreaDto(tenantId); + // workArea.TenantId = tenantId; + // bool isCreated = false; + + // if (item.WorkArea.Id == null) + // { + // //create + // _context.WorkAreas.Add(workArea); + // await _context.SaveChangesAsync(); + // responseData.workArea = workArea; + // responseMessage = "Work Area Added Successfully"; + // message = "Work Area Added"; + // isCreated = true; + // } + // else + // { + // //update + // _context.WorkAreas.Update(workArea); + // await _context.SaveChangesAsync(); + // responseData.workArea = workArea; + // responseMessage = "Work Area Updated Successfully"; + // message = "Work Area Updated"; + // } + // Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); + // var projectId = floor?.Building?.ProjectId ?? Guid.Empty; + // projectIds.Add(projectId); + // message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; + // if (isCreated) + // { + // await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); + // } + // else + // { + // await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); + // } + // } + // } + // message = $"{message} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; + + // await _signalR.SendNotificationAsync(notification); + // return Ok(ApiResponse.SuccessResponse(responseData, responseMessage, 200)); + // } + // return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400)); + + //} + + #endregion + #region =================================================================== Helper Functions =================================================================== /// @@ -1101,7 +1623,6 @@ namespace Marco.Pms.Services.Service return dbProject; } - // Helper method for background cache update private async Task UpdateCacheInBackground(Project project) { try @@ -1120,6 +1641,28 @@ namespace Marco.Pms.Services.Service } } + private async Task UpdateCacheAndNotify(Dictionary workDelta, List affectedItems) + { + try + { + // Update planned/completed work totals + var cacheUpdateTasks = workDelta.Select(kvp => + _cache.UpdatePlannedAndCompleteWorksInBuilding(kvp.Key, kvp.Value.Planned, kvp.Value.Completed)); + await Task.WhenAll(cacheUpdateTasks); + _logger.LogInfo("Background cache work totals update completed for {AreaCount} areas.", workDelta.Count); + + // Update the details of the individual work items in the cache + await _cache.ManageWorkItemDetails(affectedItems); + _logger.LogInfo("Background cache work item details update completed for {ItemCount} items.", affectedItems.Count); + + // Add SignalR notification logic here if needed + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred during background cache update/notification."); + } + } + #endregion } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index bafa582..2db004d 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -19,5 +19,9 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task>> ManageAllocationAsync(List projectAllocationDots, Guid tenantId, Employee loggedInEmployee); Task> GetProjectsByEmployeeAsync(Guid employeeId, Guid tenantId, Employee loggedInEmployee); Task>> AssigneProjectsToEmployeeAsync(List projectAllocationDtos, Guid employeeId, Guid tenantId, Employee loggedInEmployee); + Task> GetInfraDetailsAsync(Guid projectId, Guid tenantId, Employee loggedInEmployee); + Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee); + Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee); + } } -- 2.43.0 From eabd31f8cfe529c64d153d7431052f7746f37666 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Wed, 16 Jul 2025 18:15:43 +0530 Subject: [PATCH 40/50] Optimized the Manage infra API in Project Controller --- Marco.Pms.CacheHelper/ProjectCache.cs | 7 + .../{BuildingDot.cs => BuildingDto.cs} | 2 +- .../Projects/{FloorDot.cs => FloorDto.cs} | 2 +- Marco.Pms.Model/Dtos/Projects/InfraDot.cs | 9 - Marco.Pms.Model/Dtos/Projects/InfraDto.cs | 9 + .../{WorkAreaDot.cs => WorkAreaDto.cs} | 2 +- Marco.Pms.Model/Mapper/InfraMapper.cs | 6 +- Marco.Pms.Model/Utilities/ServiceResponse.cs | 8 + .../Controllers/ProjectController.cs | 154 +---- .../Helpers/CacheUpdateHelper.cs | 12 + .../MappingProfiles/MappingProfile.cs | 3 + Marco.Pms.Services/Service/ProjectServices.cs | 612 ++++++++++++------ .../ServiceInterfaces/IProjectServices.cs | 1 + 13 files changed, 488 insertions(+), 339 deletions(-) rename Marco.Pms.Model/Dtos/Projects/{BuildingDot.cs => BuildingDto.cs} (92%) rename Marco.Pms.Model/Dtos/Projects/{FloorDot.cs => FloorDto.cs} (92%) delete mode 100644 Marco.Pms.Model/Dtos/Projects/InfraDot.cs create mode 100644 Marco.Pms.Model/Dtos/Projects/InfraDto.cs rename Marco.Pms.Model/Dtos/Projects/{WorkAreaDot.cs => WorkAreaDto.cs} (91%) create mode 100644 Marco.Pms.Model/Utilities/ServiceResponse.cs diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index 833e1a0..9417724 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -95,6 +95,13 @@ namespace Marco.Pms.CacheHelper var result = await _projetCollection.DeleteOneAsync(filter); return result.DeletedCount > 0; } + public async Task RemoveProjectsFromCacheAsync(List projectIds) + { + var stringIds = projectIds.Select(id => id.ToString()).ToList(); + var filter = Builders.Filter.In(p => p.Id, stringIds); + var result = await _projetCollection.DeleteManyAsync(filter); + return result.DeletedCount > 0; + } // ------------------------------------------------------- Project InfraStructure ------------------------------------------------------- diff --git a/Marco.Pms.Model/Dtos/Projects/BuildingDot.cs b/Marco.Pms.Model/Dtos/Projects/BuildingDto.cs similarity index 92% rename from Marco.Pms.Model/Dtos/Projects/BuildingDot.cs rename to Marco.Pms.Model/Dtos/Projects/BuildingDto.cs index a5b160b..e6a7b89 100644 --- a/Marco.Pms.Model/Dtos/Projects/BuildingDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/BuildingDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace Marco.Pms.Model.Dtos.Project { - public class BuildingDot + public class BuildingDto { [Key] public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Dtos/Projects/FloorDot.cs b/Marco.Pms.Model/Dtos/Projects/FloorDto.cs similarity index 92% rename from Marco.Pms.Model/Dtos/Projects/FloorDot.cs rename to Marco.Pms.Model/Dtos/Projects/FloorDto.cs index a3d1c86..3dbe06f 100644 --- a/Marco.Pms.Model/Dtos/Projects/FloorDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/FloorDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace Marco.Pms.Model.Dtos.Project { - public class FloorDot + public class FloorDto { public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Dtos/Projects/InfraDot.cs b/Marco.Pms.Model/Dtos/Projects/InfraDot.cs deleted file mode 100644 index 7c16c09..0000000 --- a/Marco.Pms.Model/Dtos/Projects/InfraDot.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Marco.Pms.Model.Dtos.Project -{ - public class InfraDot - { - public BuildingDot? Building { get; set; } - public FloorDot? Floor { get; set; } - public WorkAreaDot? WorkArea { get; set; } - } -} diff --git a/Marco.Pms.Model/Dtos/Projects/InfraDto.cs b/Marco.Pms.Model/Dtos/Projects/InfraDto.cs new file mode 100644 index 0000000..09d1462 --- /dev/null +++ b/Marco.Pms.Model/Dtos/Projects/InfraDto.cs @@ -0,0 +1,9 @@ +namespace Marco.Pms.Model.Dtos.Project +{ + public class InfraDto + { + public BuildingDto? Building { get; set; } + public FloorDto? Floor { get; set; } + public WorkAreaDto? WorkArea { get; set; } + } +} diff --git a/Marco.Pms.Model/Dtos/Projects/WorkAreaDot.cs b/Marco.Pms.Model/Dtos/Projects/WorkAreaDto.cs similarity index 91% rename from Marco.Pms.Model/Dtos/Projects/WorkAreaDot.cs rename to Marco.Pms.Model/Dtos/Projects/WorkAreaDto.cs index 604ee3e..ffc80c4 100644 --- a/Marco.Pms.Model/Dtos/Projects/WorkAreaDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/WorkAreaDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace Marco.Pms.Model.Dtos.Project { - public class WorkAreaDot + public class WorkAreaDto { [Key] public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Mapper/InfraMapper.cs b/Marco.Pms.Model/Mapper/InfraMapper.cs index 89097d1..5364494 100644 --- a/Marco.Pms.Model/Mapper/InfraMapper.cs +++ b/Marco.Pms.Model/Mapper/InfraMapper.cs @@ -5,7 +5,7 @@ namespace Marco.Pms.Model.Mapper { public static class BuildingMapper { - public static Building ToBuildingFromBuildingDto(this BuildingDot model, Guid tenantId) + public static Building ToBuildingFromBuildingDto(this BuildingDto model, Guid tenantId) { return new Building { @@ -20,7 +20,7 @@ namespace Marco.Pms.Model.Mapper public static class FloorMapper { - public static Floor ToFloorFromFloorDto(this FloorDot model, Guid tenantId) + public static Floor ToFloorFromFloorDto(this FloorDto model, Guid tenantId) { return new Floor { @@ -34,7 +34,7 @@ namespace Marco.Pms.Model.Mapper public static class WorAreaMapper { - public static WorkArea ToWorkAreaFromWorkAreaDto(this WorkAreaDot model, Guid tenantId) + public static WorkArea ToWorkAreaFromWorkAreaDto(this WorkAreaDto model, Guid tenantId) { return new WorkArea { diff --git a/Marco.Pms.Model/Utilities/ServiceResponse.cs b/Marco.Pms.Model/Utilities/ServiceResponse.cs new file mode 100644 index 0000000..a76c45c --- /dev/null +++ b/Marco.Pms.Model/Utilities/ServiceResponse.cs @@ -0,0 +1,8 @@ +namespace Marco.Pms.Model.Utilities +{ + public class ServiceResponse + { + public object? Notification { get; set; } + public ApiResponse Response { get; set; } = ApiResponse.ErrorResponse(""); + } +} diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index a10fc66..71ef1a5 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,10 +1,8 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; -using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; -using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service; using Marco.Pms.Services.Service.ServiceInterfaces; @@ -359,6 +357,30 @@ namespace MarcoBMS.Services.Controllers #region =================================================================== Project Infrastructre Manage APIs =================================================================== + [HttpPost("manage-infra")] + public async Task ManageProjectInfra(List infraDtos) + { + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) + { + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } + + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var serviceResponse = await _projectServices.ManageProjectInfraAsync(infraDtos, tenantId, loggedInEmployee); + var response = serviceResponse.Response; + var notification = serviceResponse.Notification; + if (notification != null) + { + await _signalR.SendNotificationAsync(notification); + } + return StatusCode(response.StatusCode, response); + + } + [HttpPost("task")] public async Task CreateProjectTask([FromBody] List workItemDtos) { @@ -439,134 +461,6 @@ namespace MarcoBMS.Services.Controllers return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); } - [HttpPost("manage-infra")] - public async Task ManageProjectInfra(List infraDots) - { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - var responseData = new InfraVM { }; - string responseMessage = ""; - string message = ""; - List projectIds = new List(); - if (infraDots != null) - { - foreach (var item in infraDots) - { - if (item.Building != null) - { - - Building building = item.Building.ToBuildingFromBuildingDto(tenantId); - building.TenantId = tenantId; - - if (item.Building.Id == null) - { - //create - _context.Buildings.Add(building); - await _context.SaveChangesAsync(); - responseData.building = building; - responseMessage = "Buliding Added Successfully"; - message = "Building Added"; - await _cache.AddBuildngInfra(building.ProjectId, building); - } - else - { - //update - _context.Buildings.Update(building); - await _context.SaveChangesAsync(); - responseData.building = building; - responseMessage = "Buliding Updated Successfully"; - message = "Building Updated"; - await _cache.UpdateBuildngInfra(building.ProjectId, building); - } - projectIds.Add(building.ProjectId); - } - if (item.Floor != null) - { - Floor floor = item.Floor.ToFloorFromFloorDto(tenantId); - floor.TenantId = tenantId; - bool isCreated = false; - - if (item.Floor.Id == null) - { - //create - _context.Floor.Add(floor); - await _context.SaveChangesAsync(); - responseData.floor = floor; - responseMessage = "Floor Added Successfully"; - message = "Floor Added"; - isCreated = true; - } - else - { - //update - _context.Floor.Update(floor); - await _context.SaveChangesAsync(); - responseData.floor = floor; - responseMessage = "Floor Updated Successfully"; - message = "Floor Updated"; - } - Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); - var projectId = building?.ProjectId ?? Guid.Empty; - projectIds.Add(projectId); - message = $"{message} in Building: {building?.Name}"; - if (isCreated) - { - await _cache.AddBuildngInfra(projectId, floor: floor); - } - else - { - await _cache.UpdateBuildngInfra(projectId, floor: floor); - } - } - if (item.WorkArea != null) - { - WorkArea workArea = item.WorkArea.ToWorkAreaFromWorkAreaDto(tenantId); - workArea.TenantId = tenantId; - bool isCreated = false; - - if (item.WorkArea.Id == null) - { - //create - _context.WorkAreas.Add(workArea); - await _context.SaveChangesAsync(); - responseData.workArea = workArea; - responseMessage = "Work Area Added Successfully"; - message = "Work Area Added"; - isCreated = true; - } - else - { - //update - _context.WorkAreas.Update(workArea); - await _context.SaveChangesAsync(); - responseData.workArea = workArea; - responseMessage = "Work Area Updated Successfully"; - message = "Work Area Updated"; - } - Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); - var projectId = floor?.Building?.ProjectId ?? Guid.Empty; - projectIds.Add(projectId); - message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; - if (isCreated) - { - await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - } - else - { - await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - } - } - } - message = $"{message} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; - - await _signalR.SendNotificationAsync(notification); - return Ok(ApiResponse.SuccessResponse(responseData, responseMessage, 200)); - } - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400)); - - } - #endregion } diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 9a01b83..b0b1e06 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -478,6 +478,18 @@ namespace Marco.Pms.Services.Helpers } } + public async Task RemoveProjectsAsync(List projectIds) + { + try + { + var response = await _projectCache.RemoveProjectsFromCacheAsync(projectIds); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while deleting project list from to Cache"); + + } + } // ------------------------------------ Project Infrastructure Cache --------------------------------------- diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 50d2ea9..bf3777c 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -51,6 +51,9 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); CreateMap() .ForMember( dest => dest.Description, diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 6d811fc..32e1285 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -1033,83 +1033,360 @@ namespace Marco.Pms.Services.Service #region =================================================================== Project Infrastructre Manage APIs =================================================================== - public async Task> CreateProjectTask1(List workItemDtos, Guid tenantId, Employee loggedInEmployee) + public async Task> ManageProjectInfra(List infraDots, Guid tenantId, Employee loggedInEmployee) { - _logger.LogInfo("CreateProjectTask called with {Count} items", workItemDtos?.Count ?? 0); - - // Validate request - if (workItemDtos == null || !workItemDtos.Any()) - { - _logger.LogWarning("No work items provided in the request."); - return ApiResponse.ErrorResponse("Invalid details.", "Work Item details are not valid.", 400); - } - - var workItemsToCreate = new List(); - var workItemsToUpdate = new List(); - var responseList = new List(); + var responseData = new InfraVM { }; + string responseMessage = ""; string message = ""; - List workAreaIds = new List(); - var workItemIds = workItemDtos.Where(wi => wi.Id != null && wi.Id != Guid.Empty).Select(wi => wi.Id).ToList(); - var workItems = await _context.WorkItems.AsNoTracking().Where(wi => workItemIds.Contains(wi.Id)).ToListAsync(); - - foreach (var itemDto in workItemDtos) + List projectIds = new List(); + if (infraDots != null) { - var workItem = _mapper.Map(itemDto); - workItem.TenantId = tenantId; - var workArea = await _context.WorkAreas.Include(a => a.Floor).FirstOrDefaultAsync(a => a.Id == workItem.WorkAreaId) ?? new WorkArea(); - - Building building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == (workArea.Floor != null ? workArea.Floor.BuildingId : Guid.Empty)) ?? new Building(); - - if (itemDto.Id != null && itemDto.Id != Guid.Empty) + foreach (var item in infraDots) { - // Update existing - workItemsToUpdate.Add(workItem); - message = $"Task Updated in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; - var existingWorkItem = workItems.FirstOrDefault(wi => wi.Id == workItem.Id); - if (existingWorkItem != null) + if (item.Building != null) { - double plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; - double completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, plannedWork, completedWork); + + Building building = _mapper.Map(item.Building); + building.TenantId = tenantId; + + if (item.Building.Id == null) + { + //create + _context.Buildings.Add(building); + await _context.SaveChangesAsync(); + responseData.building = building; + responseMessage = "Buliding Added Successfully"; + message = "Building Added"; + await _cache.AddBuildngInfra(building.ProjectId, building); + } + else + { + //update + _context.Buildings.Update(building); + await _context.SaveChangesAsync(); + responseData.building = building; + responseMessage = "Buliding Updated Successfully"; + message = "Building Updated"; + await _cache.UpdateBuildngInfra(building.ProjectId, building); + } + projectIds.Add(building.ProjectId); + } + if (item.Floor != null) + { + Floor floor = _mapper.Map(item.Floor); + floor.TenantId = tenantId; + bool isCreated = false; + + if (item.Floor.Id == null) + { + //create + _context.Floor.Add(floor); + await _context.SaveChangesAsync(); + responseData.floor = floor; + responseMessage = "Floor Added Successfully"; + message = "Floor Added"; + isCreated = true; + } + else + { + //update + _context.Floor.Update(floor); + await _context.SaveChangesAsync(); + responseData.floor = floor; + responseMessage = "Floor Updated Successfully"; + message = "Floor Updated"; + } + Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); + var projectId = building?.ProjectId ?? Guid.Empty; + projectIds.Add(projectId); + message = $"{message} in Building: {building?.Name}"; + if (isCreated) + { + await _cache.AddBuildngInfra(projectId, floor: floor); + } + else + { + await _cache.UpdateBuildngInfra(projectId, floor: floor); + } + } + if (item.WorkArea != null) + { + WorkArea workArea = _mapper.Map(item.WorkArea); + workArea.TenantId = tenantId; + bool isCreated = false; + + if (item.WorkArea.Id == null) + { + //create + _context.WorkAreas.Add(workArea); + await _context.SaveChangesAsync(); + responseData.workArea = workArea; + responseMessage = "Work Area Added Successfully"; + message = "Work Area Added"; + isCreated = true; + } + else + { + //update + _context.WorkAreas.Update(workArea); + await _context.SaveChangesAsync(); + responseData.workArea = workArea; + responseMessage = "Work Area Updated Successfully"; + message = "Work Area Updated"; + } + Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); + var projectId = floor?.Building?.ProjectId ?? Guid.Empty; + projectIds.Add(projectId); + message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; + if (isCreated) + { + await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); + } + else + { + await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); + } } } - else + message = $"{message} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; + + return ApiResponse.SuccessResponse(responseData, responseMessage, 200); + } + return ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400); + + } + + public async Task ManageProjectInfraAsync(List infraDtos, Guid tenantId, Employee loggedInEmployee) + { + // 1. Guard Clause: Handle null or empty input gracefully. + if (infraDtos == null || !infraDtos.Any()) + { + return new ServiceResponse { - // Create new - workItem.Id = Guid.NewGuid(); - workItemsToCreate.Add(workItem); - message = $"Task Added in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, workItem.PlannedWork, workItem.CompletedWork); + Response = ApiResponse.ErrorResponse("Invalid details.", "No infrastructure details were provided.", 400) + }; + } + + var responseData = new InfraVM(); + var messages = new List(); + var projectIds = new HashSet(); // Use HashSet for automatic duplicate handling. + var cacheUpdateTasks = new List(); + + // --- Pre-fetch parent entities to avoid N+1 query problem --- + // 2. Gather all parent IDs needed for validation and context. + var requiredBuildingIds = infraDtos + .Where(i => i.Floor?.BuildingId != null) + .Select(i => i.Floor!.BuildingId) + .Distinct() + .ToList(); + + var requiredFloorIds = infraDtos + .Where(i => i.WorkArea?.FloorId != null) + .Select(i => i.WorkArea!.FloorId) + .Distinct() + .ToList(); + + // 3. Fetch all required parent entities in single batch queries. + var buildingsDict = await _context.Buildings + .Where(b => requiredBuildingIds.Contains(b.Id)) + .ToDictionaryAsync(b => b.Id); + + var floorsDict = await _context.Floor + .Include(f => f.Building) // Eagerly load Building for later use + .Where(f => requiredFloorIds.Contains(f.Id)) + .ToDictionaryAsync(f => f.Id); + // --- End Pre-fetching --- + + // 4. Process all entities and add them to the context's change tracker. + foreach (var item in infraDtos) + { + if (item.Building != null) + { + ProcessBuilding(item.Building, tenantId, responseData, messages, projectIds, cacheUpdateTasks); + } + if (item.Floor != null) + { + ProcessFloor(item.Floor, tenantId, responseData, messages, projectIds, cacheUpdateTasks, buildingsDict); + } + if (item.WorkArea != null) + { + ProcessWorkArea(item.WorkArea, tenantId, responseData, messages, projectIds, cacheUpdateTasks, floorsDict); + } + } + + // 5. Save all changes to the database in a single transaction. + var changedRecordCount = await _context.SaveChangesAsync(); + + // If no changes were actually made, we can exit early. + if (changedRecordCount == 0) + { + return new ServiceResponse + { + Response = ApiResponse.SuccessResponse(responseData, "No changes detected in the provided infrastructure details.", 200) + }; + } + + // 6. Execute all cache updates concurrently after the DB save is successful. + await Task.WhenAll(cacheUpdateTasks); + + // 7. Consolidate messages and create notification payload. + string finalResponseMessage = messages.LastOrDefault() ?? "Infrastructure managed successfully."; + string logMessage = $"{string.Join(", ", messages)} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds.ToList(), Message = logMessage }; + + // TODO: Dispatch the 'notification' object to your notification service. + + return new ServiceResponse + { + Notification = notification, + Response = ApiResponse.SuccessResponse(responseData, finalResponseMessage, 200) + }; + } + + /// + /// Manages a batch of infrastructure changes (creates/updates for Buildings, Floors, and WorkAreas). + /// This method is optimized to perform all database operations in a single, atomic transaction. + /// + public async Task> ManageProjectInfraAsync1(List infraDtos, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (infraDtos == null || !infraDtos.Any()) + { + _logger.LogWarning("ManageProjectInfraAsync called with null or empty DTO list."); + return ApiResponse.ErrorResponse("Invalid details.", "Infrastructure data cannot be empty.", 400); + } + + _logger.LogInfo("Begin ManageProjectInfraAsync for {DtoCount} items, TenantId: {TenantId}, User: {UserId}", infraDtos.Count, tenantId, loggedInEmployee.Id); + + // --- Step 2: Categorize DTOs by Type and Action --- + var buildingsToCreateDto = infraDtos.Where(i => i.Building != null && i.Building.Id == null).Select(i => i.Building!).ToList(); + var buildingsToUpdateDto = infraDtos.Where(i => i.Building != null && i.Building.Id != null).Select(i => i.Building!).ToList(); + var floorsToCreateDto = infraDtos.Where(i => i.Floor != null && i.Floor.Id == null).Select(i => i.Floor!).ToList(); + var floorsToUpdateDto = infraDtos.Where(i => i.Floor != null && i.Floor.Id != null).Select(i => i.Floor!).ToList(); + var workAreasToCreateDto = infraDtos.Where(i => i.WorkArea != null && i.WorkArea.Id == null).Select(i => i.WorkArea!).ToList(); + var workAreasToUpdateDto = infraDtos.Where(i => i.WorkArea != null && i.WorkArea.Id != null).Select(i => i.WorkArea!).ToList(); + + _logger.LogDebug("Categorized DTOs..."); + + try + { + // --- Step 3: Fetch all required existing data in bulk --- + + // Fetch existing entities to be updated + var buildingIdsToUpdate = buildingsToUpdateDto.Select(d => d.Id!.Value).ToList(); + var existingBuildings = await _context.Buildings.Where(b => buildingIdsToUpdate.Contains(b.Id) && b.TenantId == tenantId).ToDictionaryAsync(b => b.Id); + + var floorIdsToUpdate = floorsToUpdateDto.Select(d => d.Id!.Value).ToList(); + var existingFloors = await _context.Floor.Include(f => f.Building).Where(f => floorIdsToUpdate.Contains(f.Id) && f.TenantId == tenantId).ToDictionaryAsync(f => f.Id); + + var workAreaIdsToUpdate = workAreasToUpdateDto.Select(d => d.Id!.Value).ToList(); + var existingWorkAreas = await _context.WorkAreas.Include(wa => wa.Floor!.Building).Where(wa => workAreaIdsToUpdate.Contains(wa.Id) && wa.TenantId == tenantId).ToDictionaryAsync(wa => wa.Id); + + // Fetch parent entities for items being created to get their ProjectIds + var buildingIdsForNewFloors = floorsToCreateDto.Select(f => f.BuildingId).ToList(); + var parentBuildingsForNewFloors = await _context.Buildings.Where(b => buildingIdsForNewFloors.Contains(b.Id)).ToDictionaryAsync(b => b.Id); + + var floorIdsForNewWorkAreas = workAreasToCreateDto.Select(wa => wa.FloorId).ToList(); + var parentFloorsForNewWorkAreas = await _context.Floor.Include(f => f.Building).Where(f => floorIdsForNewWorkAreas.Contains(f.Id)).ToDictionaryAsync(f => f.Id); + + _logger.LogInfo("Fetched existing entities and parents for new items."); + + // --- Step 4: Aggregate all affected ProjectIds for Security Check --- + var affectedProjectIds = new HashSet(); + + // From buildings being created/updated + buildingsToCreateDto.ForEach(b => affectedProjectIds.Add(b.ProjectId)); + foreach (var b in existingBuildings.Values) { affectedProjectIds.Add(b.ProjectId); } + + // From floors being created/updated + foreach (var f in floorsToCreateDto) { if (parentBuildingsForNewFloors.TryGetValue(f.BuildingId, out var b)) affectedProjectIds.Add(b.ProjectId); } + foreach (var f in existingFloors.Values) { if (f.Building != null) affectedProjectIds.Add(f.Building.ProjectId); } + + // From work areas being created/updated + foreach (var wa in workAreasToCreateDto) { if (parentFloorsForNewWorkAreas.TryGetValue(wa.FloorId, out var f) && f.Building != null) affectedProjectIds.Add(f.Building.ProjectId); } + foreach (var wa in existingWorkAreas.Values) { if (wa.Floor?.Building != null) affectedProjectIds.Add(wa.Floor.Building.ProjectId); } + + // Security Check against the complete list of affected projects + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProjectInfra, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to manage infrastructure for projects.", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to manage infrastructure for one or more of the specified projects.", 403); } - responseList.Add(new WorkItemVM + // --- Step 5: Process all logic IN MEMORY, tracking changes --- + + // Process Buildings + var createdBuildings = new List(); + foreach (var dto in buildingsToCreateDto) { - WorkItemId = workItem.Id, - WorkItem = workItem - }); - workAreaIds.Add(workItem.WorkAreaId); + var newBuilding = _mapper.Map(dto); + newBuilding.TenantId = tenantId; + createdBuildings.Add(newBuilding); + } + foreach (var dto in buildingsToUpdateDto) { if (existingBuildings.TryGetValue(dto.Id!.Value, out var b)) _mapper.Map(dto, b); } + // Process Floors + var createdFloors = new List(); + foreach (var dto in floorsToCreateDto) + { + var newFloor = _mapper.Map(dto); + newFloor.TenantId = tenantId; + createdFloors.Add(newFloor); + } + foreach (var dto in floorsToUpdateDto) { if (existingFloors.TryGetValue(dto.Id!.Value, out var f)) _mapper.Map(dto, f); } + + // Process WorkAreas + var createdWorkAreas = new List(); + foreach (var dto in workAreasToCreateDto) + { + var newWorkArea = _mapper.Map(dto); + newWorkArea.TenantId = tenantId; + createdWorkAreas.Add(newWorkArea); + } + foreach (var dto in workAreasToUpdateDto) { if (existingWorkAreas.TryGetValue(dto.Id!.Value, out var wa)) _mapper.Map(dto, wa); } + + // --- Step 6: Save all database changes in a SINGLE TRANSACTION --- + if (createdBuildings.Any()) _context.Buildings.AddRange(createdBuildings); + if (createdFloors.Any()) _context.Floor.AddRange(createdFloors); + if (createdWorkAreas.Any()) _context.WorkAreas.AddRange(createdWorkAreas); + + if (_context.ChangeTracker.HasChanges()) + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Database save successful."); + } + + // --- Step 7: Update Cache using the aggregated ProjectIds (Non-blocking) --- + var finalProjectIds = affectedProjectIds.ToList(); + if (finalProjectIds.Any()) + { + _ = Task.Run(async () => + { + try + { + _logger.LogInfo("Queuing background cache update for {ProjectCount} projects.", finalProjectIds.Count); + // Assuming your cache service has a method to handle this. + await _cache.RemoveProjectsAsync(finalProjectIds); + _logger.LogInfo("Background cache update task completed for projects: {ProjectIds}", string.Join(", ", finalProjectIds)); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred during the background cache update task for projects: {ProjectIds}", string.Join(", ", finalProjectIds)); + } + }); + } + + // --- Step 8: Prepare and return a clear response --- + var responseVm = new { /* ... as before ... */ }; + return ApiResponse.SuccessResponse(responseVm, "Infrastructure changes processed successfully.", 200); } - // Apply DB changes - if (workItemsToCreate.Any()) + catch (Exception ex) { - _logger.LogInfo("Adding {Count} new work items", workItemsToCreate.Count); - await _context.WorkItems.AddRangeAsync(workItemsToCreate); - await _cache.ManageWorkItemDetails(workItemsToCreate); + _logger.LogError(ex, "An unexpected error occurred in ManageProjectInfraAsync."); + return ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500); } - - if (workItemsToUpdate.Any()) - { - _logger.LogInfo("Updating {Count} existing work items", workItemsToUpdate.Count); - _context.WorkItems.UpdateRange(workItemsToUpdate); - await _cache.ManageWorkItemDetails(workItemsToUpdate); - } - - await _context.SaveChangesAsync(); - - _logger.LogInfo("CreateProjectTask completed successfully. Created: {Created}, Updated: {Updated}", workItemsToCreate.Count, workItemsToUpdate.Count); - - return ApiResponse.SuccessResponse(responseList, message, 200); } /// @@ -1211,12 +1488,10 @@ namespace Marco.Pms.Services.Service await _context.SaveChangesAsync(); _logger.LogInfo("Successfully saved {CreatedCount} new and {UpdatedCount} updated work items.", workItemsToCreate.Count, workItemsToModify.Count); - // --- Step 5: Update Cache and SignalR AFTER successful DB save (non-blocking) --- + // --- Step 5: Update Cache and SignalR AFTER successful DB save --- var allAffectedItems = workItemsToCreate.Concat(workItemsToModify).ToList(); - _ = Task.Run(async () => - { - await UpdateCacheAndNotify(workDeltaForCache, allAffectedItems); - }); + + await UpdateCacheAndNotify(workDeltaForCache, allAffectedItems); } } catch (DbUpdateException ex) @@ -1291,133 +1566,6 @@ namespace Marco.Pms.Services.Service // return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); //} - //public async Task ManageProjectInfra(List infraDots) - //{ - // var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // var responseData = new InfraVM { }; - // string responseMessage = ""; - // string message = ""; - // List projectIds = new List(); - // if (infraDots != null) - // { - // foreach (var item in infraDots) - // { - // if (item.Building != null) - // { - - // Building building = item.Building.ToBuildingFromBuildingDto(tenantId); - // building.TenantId = tenantId; - - // if (item.Building.Id == null) - // { - // //create - // _context.Buildings.Add(building); - // await _context.SaveChangesAsync(); - // responseData.building = building; - // responseMessage = "Buliding Added Successfully"; - // message = "Building Added"; - // await _cache.AddBuildngInfra(building.ProjectId, building); - // } - // else - // { - // //update - // _context.Buildings.Update(building); - // await _context.SaveChangesAsync(); - // responseData.building = building; - // responseMessage = "Buliding Updated Successfully"; - // message = "Building Updated"; - // await _cache.UpdateBuildngInfra(building.ProjectId, building); - // } - // projectIds.Add(building.ProjectId); - // } - // if (item.Floor != null) - // { - // Floor floor = item.Floor.ToFloorFromFloorDto(tenantId); - // floor.TenantId = tenantId; - // bool isCreated = false; - - // if (item.Floor.Id == null) - // { - // //create - // _context.Floor.Add(floor); - // await _context.SaveChangesAsync(); - // responseData.floor = floor; - // responseMessage = "Floor Added Successfully"; - // message = "Floor Added"; - // isCreated = true; - // } - // else - // { - // //update - // _context.Floor.Update(floor); - // await _context.SaveChangesAsync(); - // responseData.floor = floor; - // responseMessage = "Floor Updated Successfully"; - // message = "Floor Updated"; - // } - // Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); - // var projectId = building?.ProjectId ?? Guid.Empty; - // projectIds.Add(projectId); - // message = $"{message} in Building: {building?.Name}"; - // if (isCreated) - // { - // await _cache.AddBuildngInfra(projectId, floor: floor); - // } - // else - // { - // await _cache.UpdateBuildngInfra(projectId, floor: floor); - // } - // } - // if (item.WorkArea != null) - // { - // WorkArea workArea = item.WorkArea.ToWorkAreaFromWorkAreaDto(tenantId); - // workArea.TenantId = tenantId; - // bool isCreated = false; - - // if (item.WorkArea.Id == null) - // { - // //create - // _context.WorkAreas.Add(workArea); - // await _context.SaveChangesAsync(); - // responseData.workArea = workArea; - // responseMessage = "Work Area Added Successfully"; - // message = "Work Area Added"; - // isCreated = true; - // } - // else - // { - // //update - // _context.WorkAreas.Update(workArea); - // await _context.SaveChangesAsync(); - // responseData.workArea = workArea; - // responseMessage = "Work Area Updated Successfully"; - // message = "Work Area Updated"; - // } - // Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); - // var projectId = floor?.Building?.ProjectId ?? Guid.Empty; - // projectIds.Add(projectId); - // message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; - // if (isCreated) - // { - // await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - // } - // else - // { - // await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - // } - // } - // } - // message = $"{message} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; - // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; - - // await _signalR.SendNotificationAsync(notification); - // return Ok(ApiResponse.SuccessResponse(responseData, responseMessage, 200)); - // } - // return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400)); - - //} - #endregion #region =================================================================== Helper Functions =================================================================== @@ -1663,6 +1811,82 @@ namespace Marco.Pms.Services.Service } } + private void ProcessBuilding(BuildingDto dto, Guid tenantId, InfraVM responseData, List messages, ISet projectIds, List cacheTasks) + { + Building building = _mapper.Map(dto); + building.TenantId = tenantId; + + bool isNew = dto.Id == null; + if (isNew) + { + _context.Buildings.Add(building); + messages.Add("Building Added"); + cacheTasks.Add(_cache.AddBuildngInfra(building.ProjectId, building)); + } + else + { + _context.Buildings.Update(building); + messages.Add("Building Updated"); + cacheTasks.Add(_cache.UpdateBuildngInfra(building.ProjectId, building)); + } + + responseData.building = building; + projectIds.Add(building.ProjectId); + } + + private void ProcessFloor(FloorDto dto, Guid tenantId, InfraVM responseData, List messages, ISet projectIds, List cacheTasks, IDictionary buildings) + { + Floor floor = _mapper.Map(dto); + floor.TenantId = tenantId; + + // Use the pre-fetched dictionary for parent lookup. + Building? parentBuilding = buildings.TryGetValue(dto.BuildingId, out var b) ? b : null; + + bool isNew = dto.Id == null; + if (isNew) + { + _context.Floor.Add(floor); + messages.Add($"Floor Added in Building: {parentBuilding?.Name ?? "Unknown"}"); + cacheTasks.Add(_cache.AddBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, floor: floor)); + } + else + { + _context.Floor.Update(floor); + messages.Add($"Floor Updated in Building: {parentBuilding?.Name ?? "Unknown"}"); + cacheTasks.Add(_cache.UpdateBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, floor: floor)); + } + + responseData.floor = floor; + if (parentBuilding != null) projectIds.Add(parentBuilding.ProjectId); + } + + private void ProcessWorkArea(WorkAreaDto dto, Guid tenantId, InfraVM responseData, List messages, ISet projectIds, List cacheTasks, IDictionary floors) + { + WorkArea workArea = _mapper.Map(dto); + workArea.TenantId = tenantId; + + // Use the pre-fetched dictionary for parent lookup. + Floor? parentFloor = floors.TryGetValue(dto.FloorId, out var f) ? f : null; + var parentBuilding = parentFloor?.Building; + + bool isNew = dto.Id == null; + if (isNew) + { + _context.WorkAreas.Add(workArea); + messages.Add($"Work Area Added in Building: {parentBuilding?.Name ?? "Unknown"}, on Floor: {parentFloor?.FloorName ?? "Unknown"}"); + cacheTasks.Add(_cache.AddBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, workArea: workArea, buildingId: parentBuilding?.Id)); + } + else + { + _context.WorkAreas.Update(workArea); + messages.Add($"Work Area Updated in Building: {parentBuilding?.Name ?? "Unknown"}, on Floor: {parentFloor?.FloorName ?? "Unknown"}"); + cacheTasks.Add(_cache.UpdateBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, workArea: workArea, buildingId: parentBuilding?.Id)); + } + + responseData.workArea = workArea; + if (parentBuilding != null) projectIds.Add(parentBuilding.ProjectId); + } + #endregion } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index 2db004d..f1c89cc 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -21,6 +21,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task>> AssigneProjectsToEmployeeAsync(List projectAllocationDtos, Guid employeeId, Guid tenantId, Employee loggedInEmployee); Task> GetInfraDetailsAsync(Guid projectId, Guid tenantId, Employee loggedInEmployee); Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee); + Task ManageProjectInfraAsync(List infraDtos, Guid tenantId, Employee loggedInEmployee); Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee); } -- 2.43.0 From c8ca2d5c49da430880c9732b56441ff66cd1132c Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Wed, 16 Jul 2025 18:39:29 +0530 Subject: [PATCH 41/50] Optimization of WorkItem Delete API in Project Controller --- .../Controllers/ProjectController.cs | 61 +-- Marco.Pms.Services/Service/ProjectServices.cs | 391 ++++-------------- .../ServiceInterfaces/IProjectServices.cs | 1 + 3 files changed, 90 insertions(+), 363 deletions(-) diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 71ef1a5..362c2af 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,7 +1,6 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; -using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service; @@ -11,7 +10,6 @@ using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.CodeAnalysis; -using Microsoft.EntityFrameworkCore; using MongoDB.Driver; namespace MarcoBMS.Services.Controllers @@ -410,55 +408,24 @@ namespace MarcoBMS.Services.Controllers [HttpDelete("task/{id}")] public async Task DeleteProjectTask(Guid id) { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - List workAreaIds = new List(); - WorkItem? task = await _context.WorkItems.AsNoTracking().Include(t => t.WorkArea).FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); - if (task != null) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - if (task.CompletedWork == 0) - { - var assignedTask = await _context.TaskAllocations.Where(t => t.WorkItemId == id).ToListAsync(); - if (assignedTask.Count == 0) - { - _context.WorkItems.Remove(task); - await _context.SaveChangesAsync(); - _logger.LogInfo("Task with ID {WorkItemId} has been successfully deleted.", task.Id); - - var floorId = task.WorkArea?.FloorId; - var floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == floorId); - - - workAreaIds.Add(task.WorkAreaId); - var projectId = floor?.Building?.ProjectId; - - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = $"Task Deleted in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}, in Area: {task.WorkArea?.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}" }; - await _signalR.SendNotificationAsync(notification); - await _cache.DeleteWorkItemByIdAsync(task.Id); - if (projectId != null) - { - await _cache.DeleteProjectByIdAsync(projectId.Value); - } - } - else - { - _logger.LogWarning("Task with ID {WorkItemId} is currently assigned and cannot be deleted.", task.Id); - return BadRequest(ApiResponse.ErrorResponse("Task is currently assigned and cannot be deleted.", "Task is currently assigned and cannot be deleted.", 400)); - } - } - else - { - double percentage = (task.CompletedWork / task.PlannedWork) * 100; - percentage = Math.Round(percentage, 2); - _logger.LogWarning("Task with ID {WorkItemId} is {CompletionPercentage}% complete and cannot be deleted", task.Id, percentage); - return BadRequest(ApiResponse.ErrorResponse(System.String.Format("Task is {0}% complete and cannot be deleted", percentage), System.String.Format("Task is {0}% complete and cannot be deleted", percentage), 400)); - - } + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - else + + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var serviceResponse = await _projectServices.DeleteProjectTaskAsync(id, tenantId, loggedInEmployee); + var response = serviceResponse.Response; + var notification = serviceResponse.Notification; + if (notification != null) { - _logger.LogWarning("Task with ID {WorkItemId} not found ID database", id); + await _signalR.SendNotificationAsync(notification); } - return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); + return StatusCode(response.StatusCode, response); } #endregion diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 32e1285..d7ab2ac 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -1033,130 +1033,6 @@ namespace Marco.Pms.Services.Service #region =================================================================== Project Infrastructre Manage APIs =================================================================== - public async Task> ManageProjectInfra(List infraDots, Guid tenantId, Employee loggedInEmployee) - { - var responseData = new InfraVM { }; - string responseMessage = ""; - string message = ""; - List projectIds = new List(); - if (infraDots != null) - { - foreach (var item in infraDots) - { - if (item.Building != null) - { - - Building building = _mapper.Map(item.Building); - building.TenantId = tenantId; - - if (item.Building.Id == null) - { - //create - _context.Buildings.Add(building); - await _context.SaveChangesAsync(); - responseData.building = building; - responseMessage = "Buliding Added Successfully"; - message = "Building Added"; - await _cache.AddBuildngInfra(building.ProjectId, building); - } - else - { - //update - _context.Buildings.Update(building); - await _context.SaveChangesAsync(); - responseData.building = building; - responseMessage = "Buliding Updated Successfully"; - message = "Building Updated"; - await _cache.UpdateBuildngInfra(building.ProjectId, building); - } - projectIds.Add(building.ProjectId); - } - if (item.Floor != null) - { - Floor floor = _mapper.Map(item.Floor); - floor.TenantId = tenantId; - bool isCreated = false; - - if (item.Floor.Id == null) - { - //create - _context.Floor.Add(floor); - await _context.SaveChangesAsync(); - responseData.floor = floor; - responseMessage = "Floor Added Successfully"; - message = "Floor Added"; - isCreated = true; - } - else - { - //update - _context.Floor.Update(floor); - await _context.SaveChangesAsync(); - responseData.floor = floor; - responseMessage = "Floor Updated Successfully"; - message = "Floor Updated"; - } - Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); - var projectId = building?.ProjectId ?? Guid.Empty; - projectIds.Add(projectId); - message = $"{message} in Building: {building?.Name}"; - if (isCreated) - { - await _cache.AddBuildngInfra(projectId, floor: floor); - } - else - { - await _cache.UpdateBuildngInfra(projectId, floor: floor); - } - } - if (item.WorkArea != null) - { - WorkArea workArea = _mapper.Map(item.WorkArea); - workArea.TenantId = tenantId; - bool isCreated = false; - - if (item.WorkArea.Id == null) - { - //create - _context.WorkAreas.Add(workArea); - await _context.SaveChangesAsync(); - responseData.workArea = workArea; - responseMessage = "Work Area Added Successfully"; - message = "Work Area Added"; - isCreated = true; - } - else - { - //update - _context.WorkAreas.Update(workArea); - await _context.SaveChangesAsync(); - responseData.workArea = workArea; - responseMessage = "Work Area Updated Successfully"; - message = "Work Area Updated"; - } - Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); - var projectId = floor?.Building?.ProjectId ?? Guid.Empty; - projectIds.Add(projectId); - message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; - if (isCreated) - { - await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - } - else - { - await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - } - } - } - message = $"{message} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; - var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; - - return ApiResponse.SuccessResponse(responseData, responseMessage, 200); - } - return ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400); - - } - public async Task ManageProjectInfraAsync(List infraDtos, Guid tenantId, Employee loggedInEmployee) { // 1. Guard Clause: Handle null or empty input gracefully. @@ -1244,151 +1120,6 @@ namespace Marco.Pms.Services.Service }; } - /// - /// Manages a batch of infrastructure changes (creates/updates for Buildings, Floors, and WorkAreas). - /// This method is optimized to perform all database operations in a single, atomic transaction. - /// - public async Task> ManageProjectInfraAsync1(List infraDtos, Guid tenantId, Employee loggedInEmployee) - { - // --- Step 1: Input Validation --- - if (infraDtos == null || !infraDtos.Any()) - { - _logger.LogWarning("ManageProjectInfraAsync called with null or empty DTO list."); - return ApiResponse.ErrorResponse("Invalid details.", "Infrastructure data cannot be empty.", 400); - } - - _logger.LogInfo("Begin ManageProjectInfraAsync for {DtoCount} items, TenantId: {TenantId}, User: {UserId}", infraDtos.Count, tenantId, loggedInEmployee.Id); - - // --- Step 2: Categorize DTOs by Type and Action --- - var buildingsToCreateDto = infraDtos.Where(i => i.Building != null && i.Building.Id == null).Select(i => i.Building!).ToList(); - var buildingsToUpdateDto = infraDtos.Where(i => i.Building != null && i.Building.Id != null).Select(i => i.Building!).ToList(); - var floorsToCreateDto = infraDtos.Where(i => i.Floor != null && i.Floor.Id == null).Select(i => i.Floor!).ToList(); - var floorsToUpdateDto = infraDtos.Where(i => i.Floor != null && i.Floor.Id != null).Select(i => i.Floor!).ToList(); - var workAreasToCreateDto = infraDtos.Where(i => i.WorkArea != null && i.WorkArea.Id == null).Select(i => i.WorkArea!).ToList(); - var workAreasToUpdateDto = infraDtos.Where(i => i.WorkArea != null && i.WorkArea.Id != null).Select(i => i.WorkArea!).ToList(); - - _logger.LogDebug("Categorized DTOs..."); - - try - { - // --- Step 3: Fetch all required existing data in bulk --- - - // Fetch existing entities to be updated - var buildingIdsToUpdate = buildingsToUpdateDto.Select(d => d.Id!.Value).ToList(); - var existingBuildings = await _context.Buildings.Where(b => buildingIdsToUpdate.Contains(b.Id) && b.TenantId == tenantId).ToDictionaryAsync(b => b.Id); - - var floorIdsToUpdate = floorsToUpdateDto.Select(d => d.Id!.Value).ToList(); - var existingFloors = await _context.Floor.Include(f => f.Building).Where(f => floorIdsToUpdate.Contains(f.Id) && f.TenantId == tenantId).ToDictionaryAsync(f => f.Id); - - var workAreaIdsToUpdate = workAreasToUpdateDto.Select(d => d.Id!.Value).ToList(); - var existingWorkAreas = await _context.WorkAreas.Include(wa => wa.Floor!.Building).Where(wa => workAreaIdsToUpdate.Contains(wa.Id) && wa.TenantId == tenantId).ToDictionaryAsync(wa => wa.Id); - - // Fetch parent entities for items being created to get their ProjectIds - var buildingIdsForNewFloors = floorsToCreateDto.Select(f => f.BuildingId).ToList(); - var parentBuildingsForNewFloors = await _context.Buildings.Where(b => buildingIdsForNewFloors.Contains(b.Id)).ToDictionaryAsync(b => b.Id); - - var floorIdsForNewWorkAreas = workAreasToCreateDto.Select(wa => wa.FloorId).ToList(); - var parentFloorsForNewWorkAreas = await _context.Floor.Include(f => f.Building).Where(f => floorIdsForNewWorkAreas.Contains(f.Id)).ToDictionaryAsync(f => f.Id); - - _logger.LogInfo("Fetched existing entities and parents for new items."); - - // --- Step 4: Aggregate all affected ProjectIds for Security Check --- - var affectedProjectIds = new HashSet(); - - // From buildings being created/updated - buildingsToCreateDto.ForEach(b => affectedProjectIds.Add(b.ProjectId)); - foreach (var b in existingBuildings.Values) { affectedProjectIds.Add(b.ProjectId); } - - // From floors being created/updated - foreach (var f in floorsToCreateDto) { if (parentBuildingsForNewFloors.TryGetValue(f.BuildingId, out var b)) affectedProjectIds.Add(b.ProjectId); } - foreach (var f in existingFloors.Values) { if (f.Building != null) affectedProjectIds.Add(f.Building.ProjectId); } - - // From work areas being created/updated - foreach (var wa in workAreasToCreateDto) { if (parentFloorsForNewWorkAreas.TryGetValue(wa.FloorId, out var f) && f.Building != null) affectedProjectIds.Add(f.Building.ProjectId); } - foreach (var wa in existingWorkAreas.Values) { if (wa.Floor?.Building != null) affectedProjectIds.Add(wa.Floor.Building.ProjectId); } - - // Security Check against the complete list of affected projects - var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProjectInfra, loggedInEmployee.Id); - if (!hasPermission) - { - _logger.LogWarning("Access DENIED for user {UserId} trying to manage infrastructure for projects.", loggedInEmployee.Id); - return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to manage infrastructure for one or more of the specified projects.", 403); - } - - // --- Step 5: Process all logic IN MEMORY, tracking changes --- - - // Process Buildings - var createdBuildings = new List(); - foreach (var dto in buildingsToCreateDto) - { - var newBuilding = _mapper.Map(dto); - newBuilding.TenantId = tenantId; - createdBuildings.Add(newBuilding); - } - foreach (var dto in buildingsToUpdateDto) { if (existingBuildings.TryGetValue(dto.Id!.Value, out var b)) _mapper.Map(dto, b); } - - // Process Floors - var createdFloors = new List(); - foreach (var dto in floorsToCreateDto) - { - var newFloor = _mapper.Map(dto); - newFloor.TenantId = tenantId; - createdFloors.Add(newFloor); - } - foreach (var dto in floorsToUpdateDto) { if (existingFloors.TryGetValue(dto.Id!.Value, out var f)) _mapper.Map(dto, f); } - - // Process WorkAreas - var createdWorkAreas = new List(); - foreach (var dto in workAreasToCreateDto) - { - var newWorkArea = _mapper.Map(dto); - newWorkArea.TenantId = tenantId; - createdWorkAreas.Add(newWorkArea); - } - foreach (var dto in workAreasToUpdateDto) { if (existingWorkAreas.TryGetValue(dto.Id!.Value, out var wa)) _mapper.Map(dto, wa); } - - // --- Step 6: Save all database changes in a SINGLE TRANSACTION --- - if (createdBuildings.Any()) _context.Buildings.AddRange(createdBuildings); - if (createdFloors.Any()) _context.Floor.AddRange(createdFloors); - if (createdWorkAreas.Any()) _context.WorkAreas.AddRange(createdWorkAreas); - - if (_context.ChangeTracker.HasChanges()) - { - await _context.SaveChangesAsync(); - _logger.LogInfo("Database save successful."); - } - - // --- Step 7: Update Cache using the aggregated ProjectIds (Non-blocking) --- - var finalProjectIds = affectedProjectIds.ToList(); - if (finalProjectIds.Any()) - { - _ = Task.Run(async () => - { - try - { - _logger.LogInfo("Queuing background cache update for {ProjectCount} projects.", finalProjectIds.Count); - // Assuming your cache service has a method to handle this. - await _cache.RemoveProjectsAsync(finalProjectIds); - _logger.LogInfo("Background cache update task completed for projects: {ProjectIds}", string.Join(", ", finalProjectIds)); - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred during the background cache update task for projects: {ProjectIds}", string.Join(", ", finalProjectIds)); - } - }); - } - - // --- Step 8: Prepare and return a clear response --- - var responseVm = new { /* ... as before ... */ }; - return ApiResponse.SuccessResponse(responseVm, "Infrastructure changes processed successfully.", 200); - } - catch (Exception ex) - { - _logger.LogError(ex, "An unexpected error occurred in ManageProjectInfraAsync."); - return ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500); - } - } - /// /// Creates or updates a batch of work items. /// This method is optimized to perform all database operations in a single, atomic transaction. @@ -1512,60 +1243,88 @@ namespace Marco.Pms.Services.Service return ApiResponse>.SuccessResponse(responseList, message, 200); } + public async Task DeleteProjectTaskAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + // 1. Fetch the task and its parent data in a single query. + // This is still a major optimization, avoiding a separate query for the floor/building. + WorkItem? task = await _context.WorkItems + .AsNoTracking() // Use AsNoTracking because we will re-attach for deletion later. + .Include(t => t.WorkArea) + .ThenInclude(wa => wa!.Floor) + .ThenInclude(f => f!.Building) + .FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); - //public async Task DeleteProjectTask(Guid id) - //{ - // var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - // List workAreaIds = new List(); - // WorkItem? task = await _context.WorkItems.AsNoTracking().Include(t => t.WorkArea).FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); - // if (task != null) - // { - // if (task.CompletedWork == 0) - // { - // var assignedTask = await _context.TaskAllocations.Where(t => t.WorkItemId == id).ToListAsync(); - // if (assignedTask.Count == 0) - // { - // _context.WorkItems.Remove(task); - // await _context.SaveChangesAsync(); - // _logger.LogInfo("Task with ID {WorkItemId} has been successfully deleted.", task.Id); + // 2. Guard Clause: Handle non-existent task. + if (task == null) + { + _logger.LogWarning("Attempted to delete a non-existent task with ID {WorkItemId}", id); + return new ServiceResponse + { + Response = ApiResponse.ErrorResponse("Task not found.", $"A task with ID {id} was not found.", 404) + }; + } - // var floorId = task.WorkArea?.FloorId; - // var floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == floorId); + // 3. Guard Clause: Prevent deletion if work has started. + if (task.CompletedWork > 0) + { + double percentage = Math.Round((task.CompletedWork / task.PlannedWork) * 100, 2); + _logger.LogWarning("Task with ID {WorkItemId} is {CompletionPercentage}% complete and cannot be deleted.", task.Id, percentage); + return new ServiceResponse + { + Response = ApiResponse.ErrorResponse($"Task is {percentage}% complete and cannot be deleted.", "Deletion failed because the task has progress.", 400) + }; + } + // 4. Guard Clause: Efficiently check if the task is assigned in a separate, optimized query. + // AnyAsync() is highly efficient and translates to a `SELECT TOP 1` or `EXISTS` in SQL. + bool isAssigned = await _context.TaskAllocations.AnyAsync(t => t.WorkItemId == id); + if (isAssigned) + { + _logger.LogWarning("Task with ID {WorkItemId} is currently assigned and cannot be deleted.", task.Id); + return new ServiceResponse + { + Response = ApiResponse.ErrorResponse("Task is currently assigned and cannot be deleted.", "Deletion failed because the task is assigned to an employee.", 400) + }; + } - // workAreaIds.Add(task.WorkAreaId); - // var projectId = floor?.Building?.ProjectId; + // --- Success Path: All checks passed, proceed with deletion --- - // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = $"Task Deleted in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}, in Area: {task.WorkArea?.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}" }; - // await _signalR.SendNotificationAsync(notification); - // await _cache.DeleteWorkItemByIdAsync(task.Id); - // if (projectId != null) - // { - // await _cache.DeleteProjectByIdAsync(projectId.Value); - // } - // } - // else - // { - // _logger.LogWarning("Task with ID {WorkItemId} is currently assigned and cannot be deleted.", task.Id); - // return BadRequest(ApiResponse.ErrorResponse("Task is currently assigned and cannot be deleted.", "Task is currently assigned and cannot be deleted.", 400)); - // } - // } - // else - // { - // double percentage = (task.CompletedWork / task.PlannedWork) * 100; - // percentage = Math.Round(percentage, 2); - // _logger.LogWarning("Task with ID {WorkItemId} is {CompletionPercentage}% complete and cannot be deleted", task.Id, percentage); - // return BadRequest(ApiResponse.ErrorResponse(System.String.Format("Task is {0}% complete and cannot be deleted", percentage), System.String.Format("Task is {0}% complete and cannot be deleted", percentage), 400)); + var building = task.WorkArea?.Floor?.Building; + var notification = new + { + LoggedInUserId = loggedInEmployee.Id, + Keyword = "WorkItem", + WorkAreaIds = new[] { task.WorkAreaId }, + Message = $"Task Deleted in Building: {building?.Name ?? "N/A"}, on Floor: {task.WorkArea?.Floor?.FloorName ?? "N/A"}, in Area: {task.WorkArea?.AreaName ?? "N/A"} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}" + }; - // } - // } - // else - // { - // _logger.LogWarning("Task with ID {WorkItemId} not found ID database", id); - // } - // return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); - //} + // 5. Perform the database deletion. + // We must attach a new instance or the original one without AsNoTracking. + // Since we used AsNoTracking, we create a 'stub' entity for deletion. + // This is more efficient than re-querying. + _context.WorkItems.Remove(new WorkItem { Id = task.Id }); + await _context.SaveChangesAsync(); + _logger.LogInfo("Task with ID {WorkItemId} has been successfully deleted.", task.Id); + // 6. Perform cache operations concurrently. + var cacheTasks = new List + { + _cache.DeleteWorkItemByIdAsync(task.Id) + }; + + if (building?.ProjectId != null) + { + cacheTasks.Add(_cache.DeleteProjectByIdAsync(building.ProjectId)); + } + await Task.WhenAll(cacheTasks); + + // 7. Return the final success response. + return new ServiceResponse + { + Notification = notification, + Response = ApiResponse.SuccessResponse(new { id = task.Id }, "Task deleted successfully.", 200) + }; + } #endregion #region =================================================================== Helper Functions =================================================================== diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index f1c89cc..0c7c964 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -23,6 +23,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee); Task ManageProjectInfraAsync(List infraDtos, Guid tenantId, Employee loggedInEmployee); Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee); + Task DeleteProjectTaskAsync(Guid id, Guid tenantId, Employee loggedInEmployee); } } -- 2.43.0 From 8735de3d930b18116479d3bbeaee50d59f4bc97e Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 10:17:57 +0530 Subject: [PATCH 42/50] Remove the projectHelper and ProjetsHelper and move its bussiness logic to project services --- Marco.Pms.CacheHelper/EmployeeCache.cs | 20 +++ Marco.Pms.CacheHelper/ProjectCache.cs | 74 ++++++++---- .../EmployeePermissionMongoDB.cs | 1 + .../MongoDBModels/ProjectMongoDB.cs | 1 + .../Controllers/AttendanceController.cs | 13 +- .../Controllers/EmployeeController.cs | 9 +- .../Controllers/UserController.cs | 11 +- Marco.Pms.Services/Helpers/ProjectHelper.cs | 37 ------ Marco.Pms.Services/Helpers/ProjectsHelper.cs | 81 ------------- Marco.Pms.Services/Program.cs | 1 - Marco.Pms.Services/Service/ProjectServices.cs | 114 ++++++++++++++++-- .../ServiceInterfaces/IProjectServices.cs | 6 + 12 files changed, 206 insertions(+), 162 deletions(-) delete mode 100644 Marco.Pms.Services/Helpers/ProjectHelper.cs delete mode 100644 Marco.Pms.Services/Helpers/ProjectsHelper.cs diff --git a/Marco.Pms.CacheHelper/EmployeeCache.cs b/Marco.Pms.CacheHelper/EmployeeCache.cs index f7b7066..0079106 100644 --- a/Marco.Pms.CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.CacheHelper/EmployeeCache.cs @@ -33,6 +33,8 @@ namespace Marco.Pms.CacheHelper var result = await _collection.UpdateOneAsync(filter, update, options); + await InitializeCollectionAsync(); + // 6. Return a more accurate result indicating success for both updates and upserts. // The operation is successful if an existing document was modified OR a new one was created. return result.IsAcknowledged && (result.ModifiedCount > 0 || result.UpsertedId != null); @@ -51,6 +53,7 @@ namespace Marco.Pms.CacheHelper { return false; } + await InitializeCollectionAsync(); return true; } public async Task> GetProjectsFromCache(Guid employeeId) @@ -177,5 +180,22 @@ namespace Marco.Pms.CacheHelper return true; } + + // A private method to handle the one-time setup of the collection's indexes. + private async Task InitializeCollectionAsync() + { + // 1. Define the TTL (Time-To-Live) index on the 'ExpireAt' field. + var indexKeys = Builders.IndexKeys.Ascending(x => x.ExpireAt); + var indexOptions = new CreateIndexOptions + { + // This tells MongoDB to automatically delete documents when their 'ExpireAt' time is reached. + ExpireAfter = TimeSpan.FromSeconds(0) + }; + var indexModel = new CreateIndexModel(indexKeys, indexOptions); + + // 2. Create the index. This is an idempotent operation if the index already exists. + // Use CreateOneAsync since we are only creating a single index. + await _collection.Indexes.CreateOneAsync(indexModel); + } } } diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index 9417724..df95419 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -11,27 +11,59 @@ namespace Marco.Pms.CacheHelper { public class ProjectCache { - private readonly ApplicationDbContext _context; - private readonly IMongoCollection _projetCollection; + private readonly IMongoCollection _projectCollection; private readonly IMongoCollection _taskCollection; public ProjectCache(ApplicationDbContext context, IConfiguration configuration) { var connectionString = configuration["MongoDB:ConnectionString"]; - _context = context; var mongoUrl = new MongoUrl(connectionString); var client = new MongoClient(mongoUrl); // Your MongoDB connection string var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name - _projetCollection = mongoDB.GetCollection("ProjectDetails"); + _projectCollection = mongoDB.GetCollection("ProjectDetails"); _taskCollection = mongoDB.GetCollection("WorkItemDetails"); } public async Task AddProjectDetailsToCache(ProjectMongoDB projectDetails) { - await _projetCollection.InsertOneAsync(projectDetails); + await _projectCollection.InsertOneAsync(projectDetails); + + var indexKeys = Builders.IndexKeys.Ascending(x => x.ExpireAt); + var indexOptions = new CreateIndexOptions + { + ExpireAfter = TimeSpan.Zero // required for fixed expiration time + }; + var indexModel = new CreateIndexModel(indexKeys, indexOptions); + await _projectCollection.Indexes.CreateOneAsync(indexModel); + } + // The method should focus only on inserting data. public async Task AddProjectDetailsListToCache(List projectDetailsList) { - await _projetCollection.InsertManyAsync(projectDetailsList); + // 1. Add a guard clause to avoid an unnecessary database call for an empty list. + if (projectDetailsList == null || !projectDetailsList.Any()) + { + return; + } + + // 2. Perform the insert operation. This is the only responsibility of this method. + await _projectCollection.InsertManyAsync(projectDetailsList); + await InitializeCollectionAsync(); + } + // A private method to handle the one-time setup of the collection's indexes. + private async Task InitializeCollectionAsync() + { + // 1. Define the TTL (Time-To-Live) index on the 'ExpireAt' field. + var indexKeys = Builders.IndexKeys.Ascending(x => x.ExpireAt); + var indexOptions = new CreateIndexOptions + { + // This tells MongoDB to automatically delete documents when their 'ExpireAt' time is reached. + ExpireAfter = TimeSpan.FromSeconds(0) + }; + var indexModel = new CreateIndexModel(indexKeys, indexOptions); + + // 2. Create the index. This is an idempotent operation if the index already exists. + // Use CreateOneAsync since we are only creating a single index. + await _projectCollection.Indexes.CreateOneAsync(indexModel); } public async Task UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus) { @@ -51,7 +83,7 @@ namespace Marco.Pms.CacheHelper ); // Perform the update - var result = await _projetCollection.UpdateOneAsync( + var result = await _projectCollection.UpdateOneAsync( filter: r => r.Id == project.Id.ToString(), update: updates ); @@ -71,7 +103,7 @@ namespace Marco.Pms.CacheHelper var projection = Builders.Projection.Exclude(p => p.Buildings); // Perform query - var project = await _projetCollection + var project = await _projectCollection .Find(filter) .Project(projection) .FirstOrDefaultAsync(); @@ -83,7 +115,7 @@ namespace Marco.Pms.CacheHelper List stringProjectIds = projectIds.Select(p => p.ToString()).ToList(); var filter = Builders.Filter.In(p => p.Id, stringProjectIds); var projection = Builders.Projection.Exclude(p => p.Buildings); - var projects = await _projetCollection + var projects = await _projectCollection .Find(filter) .Project(projection) .ToListAsync(); @@ -92,14 +124,14 @@ namespace Marco.Pms.CacheHelper public async Task DeleteProjectByIdFromCacheAsync(Guid projectId) { var filter = Builders.Filter.Eq(e => e.Id, projectId.ToString()); - var result = await _projetCollection.DeleteOneAsync(filter); + var result = await _projectCollection.DeleteOneAsync(filter); return result.DeletedCount > 0; } public async Task RemoveProjectsFromCacheAsync(List projectIds) { var stringIds = projectIds.Select(id => id.ToString()).ToList(); var filter = Builders.Filter.In(p => p.Id, stringIds); - var result = await _projetCollection.DeleteManyAsync(filter); + var result = await _projectCollection.DeleteManyAsync(filter); return result.DeletedCount > 0; } @@ -125,7 +157,7 @@ namespace Marco.Pms.CacheHelper var filter = Builders.Filter.Eq(p => p.Id, stringProjectId); var update = Builders.Update.Push("Buildings", buildingMongo); - var result = await _projetCollection.UpdateOneAsync(filter, update); + var result = await _projectCollection.UpdateOneAsync(filter, update); if (result.MatchedCount == 0) { @@ -155,7 +187,7 @@ namespace Marco.Pms.CacheHelper ); var update = Builders.Update.Push("Buildings.$.Floors", floorMongo); - var result = await _projetCollection.UpdateOneAsync(filter, update); + var result = await _projectCollection.UpdateOneAsync(filter, update); if (result.MatchedCount == 0) { @@ -189,7 +221,7 @@ namespace Marco.Pms.CacheHelper var update = Builders.Update.Push("Buildings.$[b].Floors.$[f].WorkAreas", workAreaMongo); var updateOptions = new UpdateOptions { ArrayFilters = arrayFilters }; - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); if (result.MatchedCount == 0) { @@ -221,7 +253,7 @@ namespace Marco.Pms.CacheHelper Builders.Update.Set("Buildings.$.Description", building.Description) ); - var result = await _projetCollection.UpdateOneAsync(filter, update); + var result = await _projectCollection.UpdateOneAsync(filter, update); if (result.MatchedCount == 0) { @@ -246,7 +278,7 @@ namespace Marco.Pms.CacheHelper var updateOptions = new UpdateOptions { ArrayFilters = arrayFilters }; var filter = Builders.Filter.Eq(p => p.Id, stringProjectId); - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); if (result.MatchedCount == 0) { @@ -272,7 +304,7 @@ namespace Marco.Pms.CacheHelper var updateOptions = new UpdateOptions { ArrayFilters = arrayFilters }; var filter = Builders.Filter.Eq(p => p.Id, stringProjectId); - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); if (result.MatchedCount == 0) { @@ -296,7 +328,7 @@ namespace Marco.Pms.CacheHelper var filter = Builders.Filter.Eq(p => p.Id, projectId.ToString()); // Project only the "Buildings" field from the document - var buildings = await _projetCollection + var buildings = await _projectCollection .Find(filter) .Project(p => p.Buildings) .FirstOrDefaultAsync(); @@ -315,7 +347,7 @@ namespace Marco.Pms.CacheHelper public async Task UpdatePlannedAndCompleteWorksInBuildingFromCache(Guid workAreaId, double plannedWork, double completedWork) { var filter = Builders.Filter.Eq("Buildings.Floors.WorkAreas._id", workAreaId.ToString()); - var project = await _projetCollection.Find(filter).FirstOrDefaultAsync(); + var project = await _projectCollection.Find(filter).FirstOrDefaultAsync(); string? selectedBuildingId = null; string? selectedFloorId = null; @@ -353,7 +385,7 @@ namespace Marco.Pms.CacheHelper .Inc("Buildings.$[b].CompletedWork", completedWork) .Inc("PlannedWork", plannedWork) .Inc("CompletedWork", completedWork); - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); } public async Task GetBuildingAndFloorByWorkAreaIdFromCache(Guid workAreaId) @@ -393,7 +425,7 @@ namespace Marco.Pms.CacheHelper { "WorkArea", "$Buildings.Floors.WorkAreas" } }) }; - var result = await _projetCollection.Aggregate(pipeline).FirstOrDefaultAsync(); + var result = await _projectCollection.Aggregate(pipeline).FirstOrDefaultAsync(); if (result == null) return null; return result; diff --git a/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs b/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs index 49c514e..fab2b84 100644 --- a/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs +++ b/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs @@ -9,5 +9,6 @@ namespace Marco.Pms.Model.MongoDBModels public List ApplicationRoleIds { get; set; } = new List(); public List PermissionIds { get; set; } = new List(); public List ProjectIds { get; set; } = new List(); + public DateTime ExpireAt { get; set; } = DateTime.UtcNow.Date.AddDays(1); } } diff --git a/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs b/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs index 7f3a557..aac0e2c 100644 --- a/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs +++ b/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs @@ -14,5 +14,6 @@ public int TeamSize { get; set; } public double CompletedWork { get; set; } public double PlannedWork { get; set; } + public DateTime ExpireAt { get; set; } = DateTime.UtcNow.Date.AddDays(1); } } diff --git a/Marco.Pms.Services/Controllers/AttendanceController.cs b/Marco.Pms.Services/Controllers/AttendanceController.cs index 1a5e4e7..7339966 100644 --- a/Marco.Pms.Services/Controllers/AttendanceController.cs +++ b/Marco.Pms.Services/Controllers/AttendanceController.cs @@ -9,6 +9,7 @@ using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.AttendanceVM; 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; @@ -28,7 +29,7 @@ namespace MarcoBMS.Services.Controllers { private readonly ApplicationDbContext _context; private readonly EmployeeHelper _employeeHelper; - private readonly ProjectsHelper _projectsHelper; + private readonly IProjectServices _projectServices; private readonly UserHelper _userHelper; private readonly S3UploadService _s3Service; private readonly PermissionServices _permission; @@ -37,11 +38,11 @@ namespace MarcoBMS.Services.Controllers public AttendanceController( - ApplicationDbContext context, EmployeeHelper employeeHelper, ProjectsHelper projectsHelper, UserHelper userHelper, S3UploadService s3Service, ILoggingService logger, PermissionServices permission, IHubContext signalR) + ApplicationDbContext context, EmployeeHelper employeeHelper, IProjectServices projectServices, UserHelper userHelper, S3UploadService s3Service, ILoggingService logger, PermissionServices permission, IHubContext signalR) { _context = context; _employeeHelper = employeeHelper; - _projectsHelper = projectsHelper; + _projectServices = projectServices; _userHelper = userHelper; _s3Service = s3Service; _logger = logger; @@ -188,7 +189,7 @@ namespace MarcoBMS.Services.Controllers List lstAttendance = await _context.Attendes.Where(c => c.ProjectID == projectId && c.AttendanceDate.Date >= fromDate.Date && c.AttendanceDate.Date <= toDate.Date && c.TenantId == TenantId).ToListAsync(); - List projectteam = await _projectsHelper.GetTeamByProject(TenantId, projectId, true); + List projectteam = await _projectServices.GetTeamByProject(TenantId, projectId, true); var jobRole = await _context.JobRoles.ToListAsync(); foreach (Attendance? attendance in lstAttendance) { @@ -295,7 +296,7 @@ namespace MarcoBMS.Services.Controllers List lstAttendance = await _context.Attendes.Where(c => c.ProjectID == projectId && c.AttendanceDate.Date == forDate && c.TenantId == TenantId).ToListAsync(); - List projectteam = await _projectsHelper.GetTeamByProject(TenantId, projectId, IncludeInActive); + List projectteam = await _projectServices.GetTeamByProject(TenantId, projectId, IncludeInActive); var idList = projectteam.Select(p => p.EmployeeId).ToList(); //var emp = await _context.Employees.Where(e => idList.Contains(e.Id)).Include(e => e.JobRole).ToListAsync(); var jobRole = await _context.JobRoles.ToListAsync(); @@ -378,7 +379,7 @@ namespace MarcoBMS.Services.Controllers List lstAttendance = await _context.Attendes.Where(c => c.ProjectID == projectId && c.Activity == ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE && c.TenantId == TenantId).ToListAsync(); - List projectteam = await _projectsHelper.GetTeamByProject(TenantId, projectId, true); + List projectteam = await _projectServices.GetTeamByProject(TenantId, projectId, true); var idList = projectteam.Select(p => p.EmployeeId).ToList(); var jobRole = await _context.JobRoles.ToListAsync(); diff --git a/Marco.Pms.Services/Controllers/EmployeeController.cs b/Marco.Pms.Services/Controllers/EmployeeController.cs index c9e19fa..d5d7f3d 100644 --- a/Marco.Pms.Services/Controllers/EmployeeController.cs +++ b/Marco.Pms.Services/Controllers/EmployeeController.cs @@ -9,6 +9,7 @@ using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Employee; 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; @@ -37,13 +38,13 @@ namespace MarcoBMS.Services.Controllers private readonly ILoggingService _logger; private readonly IHubContext _signalR; private readonly PermissionServices _permission; - private readonly ProjectsHelper _projectsHelper; + private readonly IProjectServices _projectServices; private readonly Guid tenantId; public EmployeeController(UserManager userManager, IEmailSender emailSender, ApplicationDbContext context, EmployeeHelper employeeHelper, UserHelper userHelper, IConfiguration configuration, ILoggingService logger, - IHubContext signalR, PermissionServices permission, ProjectsHelper projectsHelper) + IHubContext signalR, PermissionServices permission, IProjectServices projectServices) { _context = context; _userManager = userManager; @@ -54,7 +55,7 @@ namespace MarcoBMS.Services.Controllers _logger = logger; _signalR = signalR; _permission = permission; - _projectsHelper = projectsHelper; + _projectServices = projectServices; tenantId = _userHelper.GetTenantId(); } @@ -119,7 +120,7 @@ namespace MarcoBMS.Services.Controllers loggedInEmployee.Id, projectid ?? Guid.Empty, ShowInactive); // Step 3: Fetch project access and permissions - var projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + var projectIds = await _projectServices.GetMyProjectIdsAsync(tenantId, loggedInEmployee); var hasViewAllEmployeesPermission = await _permission.HasPermission(PermissionsMaster.ViewAllEmployees, loggedInEmployee.Id); var hasViewTeamMembersPermission = await _permission.HasPermission(PermissionsMaster.ViewTeamMembers, loggedInEmployee.Id); diff --git a/Marco.Pms.Services/Controllers/UserController.cs b/Marco.Pms.Services/Controllers/UserController.cs index 4bb4432..8269d3e 100644 --- a/Marco.Pms.Services/Controllers/UserController.cs +++ b/Marco.Pms.Services/Controllers/UserController.cs @@ -4,6 +4,7 @@ using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Employee; +using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,14 +20,14 @@ namespace MarcoBMS.Services.Controllers private readonly UserHelper _userHelper; private readonly EmployeeHelper _employeeHelper; - private readonly ProjectsHelper _projectsHelper; + private readonly IProjectServices _projectServices; private readonly RolesHelper _rolesHelper; - public UserController(EmployeeHelper employeeHelper, ProjectsHelper projectsHelper, UserHelper userHelper, RolesHelper rolesHelper) + public UserController(EmployeeHelper employeeHelper, IProjectServices projectServices, UserHelper userHelper, RolesHelper rolesHelper) { _userHelper = userHelper; _employeeHelper = employeeHelper; - _projectsHelper = projectsHelper; + _projectServices = projectServices; _rolesHelper = rolesHelper; } @@ -56,12 +57,12 @@ namespace MarcoBMS.Services.Controllers /* User with permission manage project can see all projects */ if (featurePermission != null && featurePermission.Exists(c => c.Id.ToString() == "172fc9b6-755b-4f62-ab26-55c34a330614")) { - List projects = await _projectsHelper.GetAllProjectByTanentID(emp.TenantId); + List projects = await _projectServices.GetAllProjectByTanentID(emp.TenantId); projectsId = projects.Select(c => c.Id.ToString()).ToArray(); } else { - List allocation = await _projectsHelper.GetProjectByEmployeeID(emp.Id); + List allocation = await _projectServices.GetProjectByEmployeeID(emp.Id); projectsId = allocation.Select(c => c.ProjectId.ToString()).ToArray(); } EmployeeProfile profile = new EmployeeProfile() { }; diff --git a/Marco.Pms.Services/Helpers/ProjectHelper.cs b/Marco.Pms.Services/Helpers/ProjectHelper.cs deleted file mode 100644 index f1b688e..0000000 --- a/Marco.Pms.Services/Helpers/ProjectHelper.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Projects; -using Microsoft.CodeAnalysis; -using Microsoft.EntityFrameworkCore; - - -namespace ModelServices.Helpers -{ - public class ProjectHelper - { - private readonly ApplicationDbContext _context; - public ProjectHelper(ApplicationDbContext context) - { - _context = context; - } - - public async Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive) - { - if (IncludeInactive) - { - - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId).Include(e => e.Employee).ToListAsync(); - - return employees; - } - else - { - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId && c.IsActive == true).Include(e => e.Employee).ToListAsync(); - - return employees; - } - } - - - - } -} diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs deleted file mode 100644 index e7e1dd6..0000000 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Employees; -using Marco.Pms.Model.Entitlements; -using Marco.Pms.Model.Projects; -using Marco.Pms.Services.Helpers; -using Marco.Pms.Services.Service; -using Microsoft.EntityFrameworkCore; - -namespace MarcoBMS.Services.Helpers -{ - public class ProjectsHelper - { - private readonly ApplicationDbContext _context; - private readonly CacheUpdateHelper _cache; - private readonly PermissionServices _permission; - - public ProjectsHelper(ApplicationDbContext context, CacheUpdateHelper cache, PermissionServices permission) - { - _context = context; - _cache = cache; - _permission = permission; - } - - public async Task> GetAllProjectByTanentID(Guid tanentID) - { - List alloc = await _context.Projects.Where(c => c.TenantId == tanentID).ToListAsync(); - return alloc; - } - - public async Task> GetProjectByEmployeeID(Guid employeeID) - { - List alloc = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeID && c.IsActive == true).Include(c => c.Project).ToListAsync(); - return alloc; - } - - public async Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive) - { - if (IncludeInactive) - { - - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId).Include(e => e.Employee).ToListAsync(); - - return employees; - } - else - { - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId && c.IsActive == true).Include(e => e.Employee).ToListAsync(); - - return employees; - } - } - - public async Task> GetMyProjects(Guid tenantId, Employee LoggedInEmployee) - { - var projectIds = await _cache.GetProjects(LoggedInEmployee.Id); - - if (projectIds == null) - { - var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, LoggedInEmployee.Id); - if (hasPermission) - { - var projects = await _context.Projects.Where(c => c.TenantId == tenantId).ToListAsync(); - projectIds = projects.Select(p => p.Id).ToList(); - } - else - { - var allocation = await GetProjectByEmployeeID(LoggedInEmployee.Id); - if (!allocation.Any()) - { - return new List(); - } - projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); - } - await _cache.AddProjects(LoggedInEmployee.Id, projectIds); - } - - return projectIds; - } - - } -} \ No newline at end of file diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 3c73416..3f012e2 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -167,7 +167,6 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index d7ab2ac..9406ec9 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -12,7 +12,6 @@ using Marco.Pms.Model.ViewModels.Employee; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service.ServiceInterfaces; -using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; @@ -25,7 +24,6 @@ namespace Marco.Pms.Services.Service private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate private readonly ILoggingService _logger; - private readonly ProjectsHelper _projectsHelper; private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; private readonly IMapper _mapper; @@ -34,7 +32,6 @@ namespace Marco.Pms.Services.Service IDbContextFactory dbContextFactory, ApplicationDbContext context, ILoggingService logger, - ProjectsHelper projectsHelper, PermissionServices permission, CacheUpdateHelper cache, IMapper mapper, @@ -43,7 +40,6 @@ namespace Marco.Pms.Services.Service _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _projectsHelper = projectsHelper ?? throw new ArgumentNullException(nameof(projectsHelper)); _permission = permission ?? throw new ArgumentNullException(nameof(permission)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); @@ -64,7 +60,7 @@ namespace Marco.Pms.Services.Service _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); // Step 2: Get the list of project IDs the user has access to - List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + List accessibleProjectIds = await GetMyProjects(tenantId, loggedInEmployee); if (accessibleProjectIds == null || !accessibleProjectIds.Any()) { @@ -94,7 +90,7 @@ namespace Marco.Pms.Services.Service _logger.LogInfo("Starting GetAllProjects for TenantId: {TenantId}, User: {UserId}", tenantId, loggedInEmployee.Id); // --- Step 1: Get a list of project IDs the user can access --- - List projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + List projectIds = await GetMyProjects(tenantId, loggedInEmployee); if (!projectIds.Any()) { _logger.LogInfo("User has no assigned projects. Returning empty list."); @@ -743,7 +739,7 @@ namespace Marco.Pms.Services.Service // This is a placeholder for your actual, more specific permission logic. // It should also handle the case where a user is requesting their own projects (employeeId == loggedInEmployee.Id). var hasPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); - var projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + var projectIds = await GetMyProjects(tenantId, loggedInEmployee); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} trying to view projects for employee {TargetEmployeeId}.", loggedInEmployee.Id, employeeId); @@ -1329,6 +1325,110 @@ namespace Marco.Pms.Services.Service #region =================================================================== Helper Functions =================================================================== + public async Task> GetAllProjectByTanentID(Guid tanentId) + { + List alloc = await _context.Projects.Where(c => c.TenantId == tanentId).ToListAsync(); + return alloc; + } + + public async Task> GetProjectByEmployeeID(Guid employeeId) + { + List alloc = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive == true).Include(c => c.Project).ToListAsync(); + return alloc; + } + + public async Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive) + { + if (IncludeInactive) + { + + var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId).Include(e => e.Employee).ToListAsync(); + + return employees; + } + else + { + var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId && c.IsActive == true).Include(e => e.Employee).ToListAsync(); + + return employees; + } + } + + public async Task> GetMyProjects(Guid tenantId, Employee LoggedInEmployee) + { + var projectIds = await _cache.GetProjects(LoggedInEmployee.Id); + + if (projectIds == null) + { + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, LoggedInEmployee.Id); + if (hasPermission) + { + var projects = await _context.Projects.Where(c => c.TenantId == tenantId).ToListAsync(); + projectIds = projects.Select(p => p.Id).ToList(); + } + else + { + var allocation = await GetProjectByEmployeeID(LoggedInEmployee.Id); + if (!allocation.Any()) + { + return new List(); + } + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); + } + await _cache.AddProjects(LoggedInEmployee.Id, projectIds); + } + return projectIds; + } + + public async Task> GetMyProjectIdsAsync(Guid tenantId, Employee loggedInEmployee) + { + // 1. Attempt to retrieve the list of project IDs from the cache first. + // This is the "happy path" and should be as fast as possible. + List? projectIds = await _cache.GetProjects(loggedInEmployee.Id); + + if (projectIds != null) + { + // Cache Hit: Return the cached list immediately. + return projectIds; + } + + // 2. Cache Miss: The list was not in the cache, so we must fetch it from the database. + List newProjectIds; + + // Check for the specific permission. + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, loggedInEmployee.Id); + + if (hasPermission) + { + // 3a. OPTIMIZATION: User has permission to see all projects. + // Fetch *only* the Ids directly from the database. This is far more efficient + // than fetching full Project objects and then selecting the Ids in memory. + newProjectIds = await _context.Projects + .Where(p => p.TenantId == tenantId) + .Select(p => p.Id) // This translates to `SELECT Id FROM Projects...` in SQL. + .ToListAsync(); + } + else + { + // 3b. OPTIMIZATION: User can only see projects they are allocated to. + // We go directly to the source (ProjectAllocations) and ask the database + // for a distinct list of ProjectIds. This is much better than calling a + // helper function that might return full allocation objects. + newProjectIds = await _context.ProjectAllocations + .Where(a => a.EmployeeId == loggedInEmployee.Id && a.ProjectId != Guid.Empty) + .Select(a => a.ProjectId) + .Distinct() // Pushes the DISTINCT operation to the database. + .ToListAsync(); + } + + // 4. Populate the cache with the newly fetched list (even if it's empty). + // This prevents repeated database queries for employees with no projects. + await _cache.AddProjects(loggedInEmployee.Id, newProjectIds); + + return newProjectIds; + } + + /// /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index 0c7c964..b5acccc 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -1,5 +1,6 @@ using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Projects; @@ -25,5 +26,10 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee); Task DeleteProjectTaskAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + Task> GetAllProjectByTanentID(Guid tanentId); + Task> GetProjectByEmployeeID(Guid employeeId); + Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive); + Task> GetMyProjectIdsAsync(Guid tenantId, Employee LoggedInEmployee); + } } -- 2.43.0 From 6ac28de56abea7d7cebef06ae77789d46c670608 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 12:42:02 +0530 Subject: [PATCH 43/50] Removed the reassgining of same object --- Marco.Pms.Services/Helpers/DirectoryHelper.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Marco.Pms.Services/Helpers/DirectoryHelper.cs b/Marco.Pms.Services/Helpers/DirectoryHelper.cs index 2963ff2..7af4b4d 100644 --- a/Marco.Pms.Services/Helpers/DirectoryHelper.cs +++ b/Marco.Pms.Services/Helpers/DirectoryHelper.cs @@ -1157,11 +1157,12 @@ namespace Marco.Pms.Services.Helpers List employeeBuckets = await _context.EmployeeBucketMappings.Where(b => b.EmployeeId == LoggedInEmployee.Id).ToListAsync(); var bucketIds = employeeBuckets.Select(b => b.BucketId).ToList(); - List employeeBucketVM = await _context.EmployeeBucketMappings.Where(b => bucketIds.Contains(b.BucketId)).ToListAsync(); + List bucketList = new List(); if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin)) { bucketList = await _context.Buckets.Include(b => b.CreatedBy).Where(b => b.TenantId == tenantId).ToListAsync(); + bucketIds = bucketList.Select(b => b.Id).ToList(); } else if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin) || permissionIds.Contains(PermissionsMaster.DirectoryUser)) { @@ -1173,6 +1174,8 @@ namespace Marco.Pms.Services.Helpers return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } + List employeeBucketVM = await _context.EmployeeBucketMappings.Where(b => bucketIds.Contains(b.BucketId)).ToListAsync(); + List bucketVMs = new List(); if (bucketList.Any()) { -- 2.43.0 From 5deb97d73b61b2d9f6532e9204aa3eb04a4686fb Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 12:53:01 +0530 Subject: [PATCH 44/50] Added one more condition to check if active is false while removing the employee from buckets --- Marco.Pms.Services/Helpers/DirectoryHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Marco.Pms.Services/Helpers/DirectoryHelper.cs b/Marco.Pms.Services/Helpers/DirectoryHelper.cs index 7af4b4d..3dd578e 100644 --- a/Marco.Pms.Services/Helpers/DirectoryHelper.cs +++ b/Marco.Pms.Services/Helpers/DirectoryHelper.cs @@ -1369,7 +1369,7 @@ namespace Marco.Pms.Services.Helpers _context.EmployeeBucketMappings.Add(employeeBucketMapping); assignedEmployee += 1; } - else + else if (!assignBucket.IsActive) { EmployeeBucketMapping? employeeBucketMapping = employeeBuckets.FirstOrDefault(eb => eb.BucketId == bucketId && eb.EmployeeId == assignBucket.EmployeeId); if (employeeBucketMapping != null) -- 2.43.0 From 30d614fa11c26cfec2e8ec0121f2ee35b68e413c Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 16:06:54 +0530 Subject: [PATCH 45/50] Added the logs setp in program.cs --- Marco.Pms.Services/Program.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 3f012e2..5549702 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -23,9 +23,21 @@ var builder = WebApplication.CreateBuilder(args); #region ======================= Service Configuration (Dependency Injection) ======================= #region Logging + +// Add Serilog Configuration +string? mongoConn = builder.Configuration["MongoDB:SerilogDatabaseUrl"]; +string timeString = "00:00:30"; +TimeSpan.TryParse(timeString, out TimeSpan timeSpan); + builder.Host.UseSerilog((context, config) => { - config.ReadFrom.Configuration(context.Configuration); + config.ReadFrom.Configuration(context.Configuration) + .WriteTo.MongoDB( + databaseUrl: mongoConn ?? string.Empty, + collectionName: "api-logs", + batchPostingLimit: 100, + period: timeSpan + ); }); #endregion -- 2.43.0 From 0ecf258661ae373406c744b57e175482c4487428 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 17:01:51 +0530 Subject: [PATCH 46/50] Deleted the unused variable --- .../Controllers/ProjectController.cs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 362c2af..796fd39 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,9 +1,6 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Utilities; -using Marco.Pms.Services.Helpers; -using Marco.Pms.Services.Service; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; @@ -20,30 +17,21 @@ namespace MarcoBMS.Services.Controllers public class ProjectController : ControllerBase { private readonly IProjectServices _projectServices; - private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly ILoggingService _logger; private readonly ISignalRService _signalR; - private readonly PermissionServices _permission; - private readonly CacheUpdateHelper _cache; private readonly Guid tenantId; public ProjectController( - ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, ISignalRService signalR, - CacheUpdateHelper cache, - PermissionServices permission, IProjectServices projectServices) { - _context = context; _userHelper = userHelper; _logger = logger; _signalR = signalR; - _cache = cache; - _permission = permission; _projectServices = projectServices; tenantId = userHelper.GetTenantId(); } -- 2.43.0 From c8978ee9b1d24a9043174b4ddcfb680d9df9ed66 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 16:38:39 +0530 Subject: [PATCH 47/50] added new function delete all employee entries from cache --- Marco.Pms.CacheHelper/EmployeeCache.cs | 21 +++++++++++-------- .../Helpers/CacheUpdateHelper.cs | 11 ++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/Marco.Pms.CacheHelper/EmployeeCache.cs b/Marco.Pms.CacheHelper/EmployeeCache.cs index 0079106..7c7f4b4 100644 --- a/Marco.Pms.CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.CacheHelper/EmployeeCache.cs @@ -122,16 +122,10 @@ namespace Marco.Pms.CacheHelper public async Task ClearAllProjectIdsByPermissionIdFromCache(Guid permissionId) { var filter = Builders.Filter.AnyEq(e => e.PermissionIds, permissionId.ToString()); + var update = Builders.Update.Set(e => e.ProjectIds, new List()); - var update = Builders.Update - .Set(e => e.ProjectIds, new List()); - - var result = await _collection.UpdateOneAsync(filter, update); - - if (result.MatchedCount == 0) - return false; - - return true; + var result = await _collection.UpdateManyAsync(filter, update).ConfigureAwait(false); + return result.IsAcknowledged && result.ModifiedCount > 0; } public async Task RemoveRoleIdFromCache(Guid employeeId, Guid roleId) { @@ -180,6 +174,15 @@ namespace Marco.Pms.CacheHelper return true; } + public async Task ClearAllEmployeesFromCache() + { + var result = await _collection.DeleteManyAsync(FilterDefinition.Empty); + + if (result.DeletedCount == 0) + return false; + + return true; + } // A private method to handle the one-time setup of the collection's indexes. private async Task InitializeCollectionAsync() diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index b0b1e06..9bb159b 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -811,6 +811,17 @@ namespace Marco.Pms.Services.Helpers _logger.LogWarning("Error occured while deleting Application role {RoleId} from Cache for employee {EmployeeId}: {Error}", roleId, employeeId, ex.Message); } } + public async Task ClearAllEmployees() + { + try + { + var response = await _employeeCache.ClearAllEmployeesFromCache(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while deleting all employees from Cache"); + } + } // ------------------------------------ Report Cache --------------------------------------- -- 2.43.0 From b71935dd1f5fa41dd9416381ad44208bd52f90ee Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 17:16:16 +0530 Subject: [PATCH 48/50] Removed commented code from project Cache --- Marco.Pms.CacheHelper/ProjectCache.cs | 46 ++++++--------------------- Marco.Pms.CacheHelper/ReportCache.cs | 5 +-- 2 files changed, 10 insertions(+), 41 deletions(-) diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index df95419..a9ae3af 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -23,6 +23,8 @@ namespace Marco.Pms.CacheHelper _taskCollection = mongoDB.GetCollection("WorkItemDetails"); } + #region=================================================================== Project Cache Helper =================================================================== + public async Task AddProjectDetailsToCache(ProjectMongoDB projectDetails) { await _projectCollection.InsertOneAsync(projectDetails); @@ -36,7 +38,6 @@ namespace Marco.Pms.CacheHelper await _projectCollection.Indexes.CreateOneAsync(indexModel); } - // The method should focus only on inserting data. public async Task AddProjectDetailsListToCache(List projectDetailsList) { // 1. Add a guard clause to avoid an unnecessary database call for an empty list. @@ -49,7 +50,6 @@ namespace Marco.Pms.CacheHelper await _projectCollection.InsertManyAsync(projectDetailsList); await InitializeCollectionAsync(); } - // A private method to handle the one-time setup of the collection's indexes. private async Task InitializeCollectionAsync() { // 1. Define the TTL (Time-To-Live) index on the 'ExpireAt' field. @@ -135,7 +135,9 @@ namespace Marco.Pms.CacheHelper return result.DeletedCount > 0; } - // ------------------------------------------------------- Project InfraStructure ------------------------------------------------------- + #endregion + + #region=================================================================== Project infrastructure Cache Helper =================================================================== public async Task AddBuildngInfraToCache(Guid projectId, Building? building, Floor? floor, WorkArea? workArea, Guid? buildingId) { @@ -161,11 +163,8 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Project not found while adding building. ProjectId: {ProjectId}", projectId); return; } - - //_logger.LogInfo("Building {BuildingId} added to project {ProjectId}", building.Id, projectId); return; } @@ -191,11 +190,8 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Project or building not found while adding floor. ProjectId: {ProjectId}, BuildingId: {BuildingId}", projectId, floor.BuildingId); return; } - - //_logger.LogInfo("Floor {FloorId} added to building {BuildingId} in project {ProjectId}", floor.Id, floor.BuildingId, projectId); return; } @@ -225,16 +221,10 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Project or nested structure not found while adding work area. ProjectId: {ProjectId}, BuildingId: {BuildingId}, FloorId: {FloorId}", projectId, buildingId, workArea.FloorId); return; } - - //_logger.LogInfo("WorkArea {WorkAreaId} added to floor {FloorId} in building {BuildingId}, ProjectId: {ProjectId}", workArea.Id, workArea.FloorId, buildingId, projectId); return; } - - // Fallback case when no valid data was passed - //_logger.LogWarning("No valid infra data provided to add for ProjectId: {ProjectId}", projectId); } public async Task UpdateBuildngInfraToCache(Guid projectId, Building? building, Floor? floor, WorkArea? workArea, Guid? buildingId) { @@ -257,11 +247,9 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Update failed: Project or Building not found. ProjectId: {ProjectId}, BuildingId: {BuildingId}", projectId, building.Id); return false; } - //_logger.LogInfo("Building {BuildingId} updated successfully in project {ProjectId}", building.Id, projectId); return true; } @@ -282,11 +270,8 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Update failed: Project or Floor not found. ProjectId: {ProjectId}, BuildingId: {BuildingId}, FloorId: {FloorId}", projectId, floor.BuildingId, floor.Id); return false; } - - //_logger.LogInfo("Floor {FloorId} updated successfully in Building {BuildingId}, ProjectId: {ProjectId}", floor.Id, floor.BuildingId, projectId); return true; } @@ -308,17 +293,10 @@ namespace Marco.Pms.CacheHelper if (result.MatchedCount == 0) { - //_logger.LogWarning("Update failed: Project or WorkArea not found. ProjectId: {ProjectId}, BuildingId: {BuildingId}, FloorId: {FloorId}, WorkAreaId: {WorkAreaId}", - //projectId, buildingId, workArea.FloorId, workArea.Id); return false; } - - //_logger.LogInfo("WorkArea {WorkAreaId} updated successfully in Floor {FloorId}, Building {BuildingId}, ProjectId: {ProjectId}", - //workArea.Id, workArea.FloorId, buildingId, projectId); return true; } - - //_logger.LogWarning("No update performed. Missing or invalid data for ProjectId: {ProjectId}", projectId); return false; } public async Task?> GetBuildingInfraFromCache(Guid projectId) @@ -333,15 +311,6 @@ namespace Marco.Pms.CacheHelper .Project(p => p.Buildings) .FirstOrDefaultAsync(); - //if (buildings == null) - //{ - // _logger.LogWarning("No building infrastructure found for ProjectId: {ProjectId}", projectId); - //} - //else - //{ - // _logger.LogInfo("Fetched {Count} buildings for ProjectId: {ProjectId}", buildings.Count, projectId); - //} - return buildings; } public async Task UpdatePlannedAndCompleteWorksInBuildingFromCache(Guid workAreaId, double plannedWork, double completedWork) @@ -431,8 +400,9 @@ namespace Marco.Pms.CacheHelper return result; } + #endregion - // ------------------------------------------------------- WorkItem ------------------------------------------------------- + #region=================================================================== WorkItem Cache Helper =================================================================== public async Task> GetWorkItemsByWorkAreaIdsFromCache(List workAreaIds) { @@ -517,5 +487,7 @@ namespace Marco.Pms.CacheHelper var result = await _taskCollection.DeleteOneAsync(filter); return result.DeletedCount > 0; } + + #endregion } } diff --git a/Marco.Pms.CacheHelper/ReportCache.cs b/Marco.Pms.CacheHelper/ReportCache.cs index 76009a4..66611a8 100644 --- a/Marco.Pms.CacheHelper/ReportCache.cs +++ b/Marco.Pms.CacheHelper/ReportCache.cs @@ -1,4 +1,3 @@ -using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.MongoDBModels; using Microsoft.Extensions.Configuration; using MongoDB.Driver; @@ -7,12 +6,10 @@ namespace Marco.Pms.CacheHelper { public class ReportCache { - private readonly ApplicationDbContext _context; private readonly IMongoCollection _projectReportCollection; - public ReportCache(ApplicationDbContext context, IConfiguration configuration) + public ReportCache(IConfiguration configuration) { var connectionString = configuration["MongoDB:ConnectionString"]; - _context = context; var mongoUrl = new MongoUrl(connectionString); var client = new MongoClient(mongoUrl); // Your MongoDB connection string var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name -- 2.43.0 From 7b2a3887deb27a1cc8186a05451dd44454f5aa0e Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 17 Jul 2025 17:36:58 +0530 Subject: [PATCH 49/50] Solved the rebase issues --- .../Controllers/DashboardController.cs | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index 0e01717..108a3ec 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -6,6 +6,7 @@ using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.DashBoard; using Marco.Pms.Services.Service; +using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; @@ -21,15 +22,15 @@ namespace Marco.Pms.Services.Controllers { private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; - private readonly ProjectsHelper _projectsHelper; + private readonly IProjectServices _projectServices; private readonly ILoggingService _logger; private readonly PermissionServices _permissionServices; public static readonly Guid ActiveId = Guid.Parse("b74da4c2-d07e-46f2-9919-e75e49b12731"); - public DashboardController(ApplicationDbContext context, UserHelper userHelper, ProjectsHelper projectsHelper, ILoggingService logger, PermissionServices permissionServices) + public DashboardController(ApplicationDbContext context, UserHelper userHelper, IProjectServices projectServices, ILoggingService logger, PermissionServices permissionServices) { _context = context; _userHelper = userHelper; - _projectsHelper = projectsHelper; + _projectServices = projectServices; _logger = logger; _permissionServices = permissionServices; } @@ -182,11 +183,13 @@ namespace Marco.Pms.Services.Controllers // --- Step 1: Get the list of projects the user can access --- // This query is more efficient as it only selects the IDs needed. - var projects = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - var accessibleActiveProjectIds = projects - .Where(p => p.ProjectStatusId == ActiveId) + var projects = await _projectServices.GetMyProjectIdsAsync(tenantId, loggedInEmployee); + + var accessibleActiveProjectIds = await _context.Projects + .Where(p => p.ProjectStatusId == ActiveId && projects.Contains(p.Id)) .Select(p => p.Id) - .ToList(); + .ToListAsync(); + if (!accessibleActiveProjectIds.Any()) { _logger.LogInfo("User {UserId} has no accessible active projects.", loggedInEmployee.Id); @@ -199,7 +202,7 @@ namespace Marco.Pms.Services.Controllers if (projectId.HasValue) { // Security Check: Ensure the requested project is in the user's accessible list. - var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value.ToString()); + var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId} (not active or not accessible).", loggedInEmployee.Id, projectId.Value); @@ -250,7 +253,7 @@ namespace Marco.Pms.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred in GetTotalEmployees for projectId {ProjectId} \n {Error}", projectId ?? Guid.Empty, ex.Message); + _logger.LogError(ex, "An unexpected error occurred in GetTotalEmployees for projectId {ProjectId}", projectId ?? Guid.Empty); return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); } } @@ -281,7 +284,7 @@ namespace Marco.Pms.Services.Controllers // --- Logic for a SINGLE Project --- // 2a. Security Check: Verify permission for the specific project. - var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value.ToString()); + var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId.Value); @@ -301,8 +304,8 @@ namespace Marco.Pms.Services.Controllers // --- Logic for ALL Accessible Projects --- // 2c. Get a list of all projects the user is allowed to see. - var accessibleProject = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - var accessibleProjectIds = accessibleProject.Select(p => p.Id).ToList(); + var accessibleProjectIds = await _projectServices.GetMyProjectIdsAsync(tenantId, loggedInEmployee); + if (!accessibleProjectIds.Any()) { _logger.LogInfo("User {UserId} has no accessible projects.", loggedInEmployee.Id); @@ -341,7 +344,7 @@ namespace Marco.Pms.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred in GetTotalTasks for projectId {ProjectId} \n {Error}", projectId ?? Guid.Empty, ex.Message); + _logger.LogError(ex, "An unexpected error occurred in GetTotalTasks for projectId {ProjectId}", projectId ?? Guid.Empty); return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); } } -- 2.43.0 From 328c6ec4e3fbbd90fb976b25ca7296d6915815e0 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Fri, 18 Jul 2025 16:03:53 +0530 Subject: [PATCH 50/50] When sending the report before data has been taken from cache not from database change it to both --- Marco.Pms.CacheHelper/ProjectCache.cs | 13 ++++ .../Controllers/ReportController.cs | 78 ------------------- .../Helpers/CacheUpdateHelper.cs | 25 ++++-- Marco.Pms.Services/Helpers/ReportHelper.cs | 10 +-- 4 files changed, 37 insertions(+), 89 deletions(-) diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index a9ae3af..10eb623 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -110,6 +110,19 @@ namespace Marco.Pms.CacheHelper return project; } + public async Task GetProjectDetailsWithBuildingsFromCache(Guid projectId) + { + + // Build filter and projection to exclude large 'Buildings' list + var filter = Builders.Filter.Eq(p => p.Id, projectId.ToString()); + + // Perform query + var project = await _projectCollection + .Find(filter) + .FirstOrDefaultAsync(); + + return project; + } public async Task> GetProjectDetailsListFromCache(List projectIds) { List stringProjectIds = projectIds.Select(p => p.ToString()).ToList(); diff --git a/Marco.Pms.Services/Controllers/ReportController.cs b/Marco.Pms.Services/Controllers/ReportController.cs index 87382d7..a46c391 100644 --- a/Marco.Pms.Services/Controllers/ReportController.cs +++ b/Marco.Pms.Services/Controllers/ReportController.cs @@ -370,84 +370,6 @@ namespace Marco.Pms.Services.Controllers 200)); } - //[HttpPost("add-report-mail1")] - //public async Task StoreProjectStatistics1() - //{ - - // Guid tenantId = _userHelper.GetTenantId(); - - // // Use AsNoTracking() for read-only queries to improve performance - // List mailDetails = await _context.MailDetails - // .AsNoTracking() - // .Include(m => m.MailBody) - // .Where(m => m.TenantId == tenantId) - // .ToListAsync(); - - // var groupedMails = mailDetails - // .GroupBy(m => new { m.ProjectId, m.MailListId }) - // .Select(g => new - // { - // ProjectId = g.Key.ProjectId, - // MailListId = g.Key.MailListId, - // Recipients = g.Select(m => m.Recipient).Distinct().ToList(), - // MailBody = g.FirstOrDefault()?.MailBody?.Body ?? "", - // Subject = g.FirstOrDefault()?.MailBody?.Subject ?? string.Empty, - // }) - // .ToList(); - // foreach (var groupMail in groupedMails) - // { - // var projectId = groupMail.ProjectId; - // var body = groupMail.MailBody; - // var subject = groupMail.Subject; - // var receivers = groupMail.Recipients; - // if (projectId == Guid.Empty) - // { - // _logger.LogError("Provided empty project ID while fetching project report."); - // return NotFound(ApiResponse.ErrorResponse("Provided empty Project ID.", "Provided empty Project ID.", 400)); - // } - - - // var statisticReport = await _reportHelper.GetDailyProjectReport(projectId, tenantId); - - // if (statisticReport == null) - // { - // _logger.LogWarning("User attempted to fetch project progress for project ID {ProjectId} but not found.", projectId); - // return NotFound(ApiResponse.ErrorResponse("Project not found.", "Project not found.", 404)); - // } - // var date = statisticReport.Date.ToString("dd-MMM-yyyy", CultureInfo.InvariantCulture); - - // // Send Email - // var emailBody = await _emailSender.SendProjectStatisticsEmail(new List(), body, subject, statisticReport); - // var subjectReplacements = new Dictionary - // { - // {"DATE", date }, - // {"PROJECT_NAME", statisticReport.ProjectName} - // }; - // foreach (var item in subjectReplacements) - // { - // subject = subject.Replace($"{{{{{item.Key}}}}}", item.Value); - // } - // string env = _configuration["environment:Title"] ?? string.Empty; - // if (string.IsNullOrWhiteSpace(env)) - // { - // subject = $"{subject}"; - // } - // else - // { - // subject = $"({env}) {subject}"; - // } - // var mail = new ProjectReportEmailMongoDB - // { - // IsSent = false, - // Body = emailBody, - // Receivers = receivers, - // Subject = subject, - // }; - // await _cache.AddProjectReportMail(mail); - // } - // return Ok(ApiResponse.SuccessResponse("Project Report Mail is stored in MongoDB", "Project Report Mail is stored in MongoDB", 200)); - //} - [HttpPost("add-report-mail")] public async Task StoreProjectStatistics() { diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 9bb159b..d942ab1 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -200,7 +200,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogWarning("Error occurred while adding project {ProjectId} to Cache: {Error}", project.Id, ex.Message); + _logger.LogError(ex, "Error occurred while adding project {ProjectId} to Cache", project.Id); } } public async Task AddProjectDetailsList(List projects) @@ -415,7 +415,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogWarning("Error occurred while adding project list to Cache: {Error}", ex.Message); + _logger.LogError(ex, "Error occurred while adding project list to Cache"); } } public async Task UpdateProjectDetailsOnly(Project project) @@ -429,7 +429,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogWarning("Error occured while updating project {ProjectId} to Cache: {Error}", project.Id, ex.Message); + _logger.LogError(ex, "Error occured while updating project {ProjectId} to Cache", project.Id); return false; } } @@ -442,7 +442,20 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogWarning("Error occured while getting project {ProjectId} to Cache: {Error}", ex.Message); + _logger.LogError(ex, "Error occured while getting project {ProjectId} to Cache"); + return null; + } + } + public async Task GetProjectDetailsWithBuildings(Guid projectId) + { + try + { + var response = await _projectCache.GetProjectDetailsWithBuildingsFromCache(projectId); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while getting project {ProjectId} to Cache"); return null; } } @@ -462,7 +475,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogWarning("Error occured while getting list of project details from to Cache: {Error}", ex.Message); + _logger.LogError(ex, "Error occured while getting list of project details from to Cache"); return null; } } @@ -474,7 +487,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogWarning("Error occured while deleting project from to Cache: {Error}", ex.Message); + _logger.LogError(ex, "Error occured while deleting project from to Cache"); } } diff --git a/Marco.Pms.Services/Helpers/ReportHelper.cs b/Marco.Pms.Services/Helpers/ReportHelper.cs index 4ec9453..35dcf8b 100644 --- a/Marco.Pms.Services/Helpers/ReportHelper.cs +++ b/Marco.Pms.Services/Helpers/ReportHelper.cs @@ -28,7 +28,7 @@ namespace Marco.Pms.Services.Helpers { // await _cache.GetBuildingAndFloorByWorkAreaId(); DateTime reportDate = DateTime.UtcNow.AddDays(-1).Date; - var project = await _cache.GetProjectDetails(projectId); + var project = await _cache.GetProjectDetailsWithBuildings(projectId); if (project == null) { var projectSQL = await _context.Projects @@ -91,7 +91,7 @@ namespace Marco.Pms.Services.Helpers BuildingName = b.BuildingName, Description = b.Description }).ToList(); - if (buildings == null) + if (!buildings.Any()) { buildings = await _context.Buildings .Where(b => b.ProjectId == projectId) @@ -113,7 +113,7 @@ namespace Marco.Pms.Services.Helpers BuildingId = f.BuildingId, FloorName = f.FloorName })).ToList(); - if (floors == null) + if (!floors.Any()) { var buildingIds = buildings.Select(b => Guid.Parse(b.Id)).ToList(); floors = await _context.Floor @@ -131,7 +131,7 @@ namespace Marco.Pms.Services.Helpers areas = project.Buildings .SelectMany(b => b.Floors) .SelectMany(f => f.WorkAreas).ToList(); - if (areas == null) + if (!areas.Any()) { var floorIds = floors.Select(f => Guid.Parse(f.Id)).ToList(); areas = await _context.WorkAreas @@ -149,7 +149,7 @@ namespace Marco.Pms.Services.Helpers // fetch Work Items workItems = await _cache.GetWorkItemsByWorkAreaIds(areaIds); - if (workItems == null) + if (workItems == null || !workItems.Any()) { workItems = await _context.WorkItems .Include(w => w.ActivityMaster) -- 2.43.0