208 lines
8.5 KiB
C#
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><br></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;
|
|
}
|