using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Mail; 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 { [Route("api/[controller]")] [ApiController] [Authorize] public class ReportController : ControllerBase { private readonly ApplicationDbContext _context; private readonly IEmailSender _emailSender; private readonly ILoggingService _logger; private readonly UserHelper _userHelper; private readonly IWebHostEnvironment _env; private readonly 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; _logger = logger; _userHelper = userHelper; _env = env; _reportHelper = reportHelper; _configuration = configuration; _cache = cache; _serviceScopeFactory = serviceScopeFactory; } /// /// 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(); 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(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)); } 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, }; 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(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(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)); } } [HttpPost("mail-template1")] public async Task AddMailTemplate1([FromBody] MailTemeplateDto mailTemeplateDto) { Guid tenantId = _userHelper.GetTenantId(); if (string.IsNullOrWhiteSpace(mailTemeplateDto.Body) && string.IsNullOrWhiteSpace(mailTemeplateDto.Title)) { _logger.LogWarning("User tries to set email template but send invalid data"); return BadRequest(ApiResponse.ErrorResponse("Provided Invalid data", "Provided Invalid data", 400)); } var existngTemalate = await _context.MailingList.FirstOrDefaultAsync(t => t.Title.ToLower() == mailTemeplateDto.Title.ToLower()); if (existngTemalate != null) { _logger.LogWarning("User tries to set email template, but title already existed in database"); return BadRequest(ApiResponse.ErrorResponse("Email title is already existed", "Email title is already existed", 400)); } MailingList mailingList = new MailingList { Title = mailTemeplateDto.Title, Body = mailTemeplateDto.Body, Subject = mailTemeplateDto.Subject, Keywords = mailTemeplateDto.Keywords, TenantId = tenantId }; _context.MailingList.Add(mailingList); await _context.SaveChangesAsync(); 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(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)); } 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(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(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)); } } [HttpGet("project-statistics")] public async Task SendProjectReport() { Guid tenantId = _userHelper.GetTenantId(); // 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() .Where(m => m.TenantId == tenantId) .GroupBy(m => new { m.ProjectId, m.MailListId }) .Select(g => new { ProjectId = g.Key.ProjectId, Recipients = g.Select(m => m.Recipient).Distinct().ToList(), // 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() }) .ToListAsync(); if (!projectMailGroups.Any()) { 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()) { // 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(ex, "Failed to send report for project {ProjectId}", mailGroup.ProjectId); Interlocked.Increment(ref failureCount); } } }).ToList(); await Task.WhenAll(sendTasks); var summaryMessage = $"Processing complete. Success: {successCount}, Not Found: {notFoundCount}, Invalid ID: {invalidIdCount}, Failures: {failureCount}."; _logger.LogInfo( "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 { successCount, notFoundCount, invalidIdCount, failureCount }, summaryMessage, 200)); } [HttpPost("add-report-mail")] public async Task StoreProjectStatistics() { Guid tenantId = _userHelper.GetTenantId(); // 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.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; // 2. Process each group concurrently, but with isolated DBContexts. var processingTasks = projectMailGroups.Select(async group => { // 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 // The rest of the logic is the same, but now it's thread-safe. try { 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(ex, "Failed to process project report for ProjectId {ProjectId}", group.ProjectId); } } }); // 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)); } return Ok(ApiResponse.SuccessResponse(mailList, "Fetched list of mail body successfully", 200)); } } }