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; /// /// SuperAdmin-only tool for sending platform-wide broadcast emails to tenant /// company contacts. Emails are sent one at a time via /// rather than bulk API because each message requires a personalised unsubscribe link /// containing the company's unique MarketingUnsubscribeToken. /// [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class EmailBroadcastController : Controller { private readonly ApplicationDbContext _db; private readonly IEmailService _emailService; private readonly ILogger _logger; public EmailBroadcastController( ApplicationDbContext db, IEmailService emailService, ILogger logger) { _db = db; _emailService = emailService; _logger = logger; } /// /// 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. /// public async Task Index() { await PopulateViewBag(); return View(new BroadcastForm()); } /// Returns JSON count of recipients for the current filter — used for the live preview. [HttpGet] public async Task RecipientCount(string target, string? plan, int[]? companyIds) { var recipients = await BuildRecipientListAsync(target, plan, companyIds); return Json(new { count = recipients.Count }); } /// /// 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. /// /// Each email body is HTML-encoded (then line-breaks converted to /// <br>) 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. /// /// [HttpPost, ValidateAntiForgeryToken] public async Task 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", "
"); foreach (var (email, name, unsubToken) in recipients) { var unsubUrl = $"{baseUrl}/Unsubscribe/BroadcastEmail/{unsubToken}"; var htmlBody = $@"

{encodedBody}


This message was sent by the Powder Coating Logix platform team.
Unsubscribe from platform announcements

"; 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)); } /// /// Builds the list of (email, name, unsubscribe-token) tuples for the given /// targeting criteria. Companies are excluded when MarketingEmailOptOut /// is true — honouring prior unsubscribes — or when PrimaryContactEmail /// is missing. The "specific" target requires at least one companyIds /// entry and returns an empty list otherwise to prevent accidental all-company sends. /// IgnoreQueryFilters() is required because this query spans companies. /// private async Task> 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(); } /// /// 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 and the /// validation-failure branch of . /// 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; }