452 lines
23 KiB
C#

using Marco.Pms.DataAccess.Data;
using Marco.Pms.Model.Dtos.Mail;
using Marco.Pms.Model.Mail;
using Marco.Pms.Model.MongoDBModels.Utility;
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;
}
/// <summary>
/// Adds new mail details for a project report.
/// </summary>
/// <param name="mailDetailsDto">The mail details data.</param>
/// <returns>An API response indicating success or failure.</returns>
[HttpPost("mail-details")] // More specific route for adding mail details
public async Task<IActionResult> 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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<MailDetails>.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<object>.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<object>.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500));
}
}
/// <summary>
/// Adds a new mail template.
/// </summary>
/// <param name="mailTemplateDto">The mail template data.</param>
/// <returns>An API response indicating success or failure.</returns>
[HttpPost("mail-template")] // More specific route for adding a template
public async Task<IActionResult> 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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<MailingList>.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<object>.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<object>.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500));
}
}
[HttpGet("project-statistics")]
public async Task<IActionResult> 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<object>.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<ReportHelper>();
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<object>.SuccessResponse(
new { successCount, notFoundCount, invalidIdCount, failureCount },
summaryMessage,
200));
}
[HttpPost("add-report-mail")]
public async Task<IActionResult> 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<object>.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<ReportHelper>();
var emailSender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
var cache = scope.ServiceProvider.GetRequiredService<CacheUpdateHelper>(); // 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<string>(), 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<object>.SuccessResponse(
$"{projectMailGroups.Count} Project Report Mail(s) are queued for storage.",
"Project Report Mail processing initiated.",
200));
}
[HttpGet("report-mail")]
public async Task<IActionResult> GetProjectStatisticsFromCache()
{
var mailList = await _cache.GetProjectReportMail(false);
if (mailList == null)
{
return NotFound(ApiResponse<object>.ErrorResponse("Not mail found", "Not mail found", 404));
}
return Ok(ApiResponse<object>.SuccessResponse(mailList, "Fetched list of mail body successfully", 200));
}
}
}