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)