Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs
T
2026-04-23 21:38:24 -04:00

208 lines
8.5 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only tool for sending platform-wide broadcast emails to tenant
/// company contacts. Emails are sent one at a time via <see cref="IEmailService"/>
/// rather than bulk API because each message requires a personalised unsubscribe link
/// containing the company's unique <c>MarketingUnsubscribeToken</c>.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class EmailBroadcastController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IEmailService _emailService;
private readonly ILogger<EmailBroadcastController> _logger;
public EmailBroadcastController(
ApplicationDbContext db,
IEmailService emailService,
ILogger<EmailBroadcastController> logger)
{
_db = db;
_emailService = emailService;
_logger = logger;
}
/// <summary>
/// Renders the broadcast compose form, pre-populating ViewBag with plan configs
/// and the active company list so the targeting dropdowns are populated without
/// a separate AJAX call.
/// </summary>
public async Task<IActionResult> Index()
{
await PopulateViewBag();
return View(new BroadcastForm());
}
/// <summary>Returns JSON count of recipients for the current filter — used for the live preview.</summary>
[HttpGet]
public async Task<IActionResult> RecipientCount(string target, string? plan, int[]? companyIds)
{
var recipients = await BuildRecipientListAsync(target, plan, companyIds);
return Json(new { count = recipients.Count });
}
/// <summary>
/// Sends the composed broadcast email to all recipients matching the chosen
/// targeting criteria. Aborts early (with a validation error) if no recipients
/// are found, to prevent accidental empty sends.
/// <para>
/// Each email body is HTML-encoded (then line-breaks converted to
/// <c>&lt;br&gt;</c>) and wrapped in a branded container that appends a
/// per-company unsubscribe footer. Failures are counted and reported in the
/// success banner rather than aborting the remainder of the batch, so a single
/// bad address does not block delivery to every other recipient.
/// </para>
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Send(BroadcastForm form)
{
if (!ModelState.IsValid)
{
await PopulateViewBag();
return View("Index", form);
}
var recipients = await BuildRecipientListAsync(form.Target, form.PlanFilter, form.CompanyIds);
if (recipients.Count == 0)
{
TempData["Error"] = "No recipients matched the selected criteria.";
await PopulateViewBag();
return View("Index", form);
}
int sent = 0, failed = 0;
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var encodedBody = System.Net.WebUtility.HtmlEncode(form.Body).Replace("\n", "<br>");
foreach (var (email, name, unsubToken) in recipients)
{
var unsubUrl = $"{baseUrl}/Unsubscribe/BroadcastEmail/{unsubToken}";
var htmlBody = $@"
<div style=""font-family:sans-serif;max-width:600px;margin:0 auto"">
<p>{encodedBody}</p>
<hr style=""border:none;border-top:1px solid #eee;margin:24px 0"">
<p style=""font-size:12px;color:#999"">
This message was sent by the Powder Coating Logix platform team.<br>
<a href=""{unsubUrl}"" style=""color:#999"">Unsubscribe from platform announcements</a>
</p>
</div>";
var (success, error) = await _emailService.SendEmailAsync(
email, name, form.Subject, form.Body, htmlBody);
if (success) sent++;
else
{
failed++;
_logger.LogWarning("Broadcast email failed for {Email}: {Error}", email, error);
}
}
TempData["Success"] = $"Broadcast sent: {sent} delivered, {failed} failed. Total recipients: {recipients.Count}.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Builds the list of (email, name, unsubscribe-token) tuples for the given
/// targeting criteria. Companies are excluded when <c>MarketingEmailOptOut</c>
/// is true — honouring prior unsubscribes — or when <c>PrimaryContactEmail</c>
/// is missing. The "specific" target requires at least one <c>companyIds</c>
/// entry and returns an empty list otherwise to prevent accidental all-company sends.
/// <c>IgnoreQueryFilters()</c> is required because this query spans companies.
/// </summary>
private async Task<List<(string Email, string Name, string UnsubToken)>> BuildRecipientListAsync(
string? target, string? planFilter, int[]? companyIds)
{
var companyQuery = _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted && c.IsActive && !string.IsNullOrEmpty(c.PrimaryContactEmail)
&& !c.MarketingEmailOptOut);
target ??= "active";
switch (target)
{
case "active":
companyQuery = companyQuery.Where(c =>
c.SubscriptionStatus == SubscriptionStatus.Active ||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod);
break;
case "plan":
if (!string.IsNullOrWhiteSpace(planFilter) && int.TryParse(planFilter, out var planInt))
companyQuery = companyQuery.Where(c => c.SubscriptionPlan == planInt);
break;
case "status_grace":
companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod);
break;
case "status_expired":
companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.Expired);
break;
case "specific":
if (companyIds != null && companyIds.Length > 0)
companyQuery = companyQuery.Where(c => companyIds.Contains(c.Id));
else
return new List<(string, string, string)>();
break;
case "all":
default:
break;
}
var companies = await companyQuery
.Select(c => new { c.PrimaryContactEmail, c.CompanyName, c.MarketingUnsubscribeToken })
.ToListAsync();
return companies
.Where(c => !string.IsNullOrWhiteSpace(c.PrimaryContactEmail))
.Select(c => (c.PrimaryContactEmail!, c.CompanyName, c.MarketingUnsubscribeToken))
.ToList();
}
/// <summary>
/// Hydrates ViewBag with the data sets needed by the broadcast compose view:
/// active subscription plan configs (for the plan-filter dropdown),
/// all non-deleted active companies (for the specific-company picker),
/// and a live count of active/grace-period companies shown in the UI summary.
/// Centralised here so it can be called from both <see cref="Index"/> and the
/// validation-failure branch of <see cref="Send"/>.
/// </summary>
private async Task PopulateViewBag()
{
ViewBag.PlanConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync();
ViewBag.Companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted && c.IsActive)
.OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName })
.ToListAsync();
ViewBag.ActiveCount = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.CountAsync(c => !c.IsDeleted && c.IsActive &&
(c.SubscriptionStatus == SubscriptionStatus.Active ||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod));
}
}
public class BroadcastForm
{
public string Target { get; set; } = "active";
public string? PlanFilter { get; set; }
public int[]? CompanyIds { get; set; }
[System.ComponentModel.DataAnnotations.Required]
public string Subject { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required]
public string Body { get; set; } = string.Empty;
}