Add admin email wizard and logging
This commit is contained in:
@@ -1,207 +1,597 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
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 static readonly Regex ScriptRegex = new(
|
||||
@"<\s*(script|style|iframe|object|embed|form|input|button|textarea|select|meta|link)\b[^>]*>.*?<\s*/\s*\1\s*>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
private static readonly Regex CommentRegex = new(
|
||||
@"<!--.*?-->",
|
||||
RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
private static readonly Regex TagRegex = new(
|
||||
@"<\s*(/?)\s*([a-z0-9]+)([^>]*)>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex HrefRegex = new(
|
||||
@"href\s*=\s*(['""]?)([^'"">\s]+)\1",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly HashSet<string> AllowedTags =
|
||||
[
|
||||
"a", "p", "br", "strong", "b", "em", "i", "u",
|
||||
"ul", "ol", "li", "blockquote", "h1", "h2", "h3", "h4"
|
||||
];
|
||||
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IPlatformSettingsService _platformSettings;
|
||||
private readonly ILogger<EmailBroadcastController> _logger;
|
||||
|
||||
public EmailBroadcastController(
|
||||
ApplicationDbContext db,
|
||||
IEmailService emailService,
|
||||
IPlatformSettingsService platformSettings,
|
||||
ILogger<EmailBroadcastController> logger)
|
||||
{
|
||||
_db = db;
|
||||
_emailService = emailService;
|
||||
_platformSettings = platformSettings;
|
||||
_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)
|
||||
public IActionResult Index() => View(new AdminEmailComposeModel());
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SelectCompanies(AdminEmailComposeModel form)
|
||||
{
|
||||
var recipients = await BuildRecipientListAsync(target, plan, companyIds);
|
||||
return Json(new { count = recipients.Count });
|
||||
NormalizeComposeModel(form);
|
||||
|
||||
if (!ValidateComposeModel(form))
|
||||
return View("Index", form);
|
||||
|
||||
var viewModel = await BuildSelectionModelAsync(form);
|
||||
return View(viewModel);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
public IActionResult BackToCompose(AdminEmailComposeModel form)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
NormalizeComposeModel(form);
|
||||
return View("Index", form);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Preview(AdminEmailSelectionModel form)
|
||||
{
|
||||
NormalizeComposeModel(form);
|
||||
|
||||
if (!ValidateComposeModel(form))
|
||||
return View("Index", new AdminEmailComposeModel
|
||||
{
|
||||
Subject = form.Subject,
|
||||
BodyHtml = form.BodyHtml
|
||||
});
|
||||
|
||||
if (!ValidateCompanySelection(form))
|
||||
return View("SelectCompanies", await BuildSelectionModelAsync(form));
|
||||
|
||||
var preview = await BuildPreviewModelAsync(form);
|
||||
return View(preview);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> BackToSelectCompanies(AdminEmailSelectionModel form)
|
||||
{
|
||||
NormalizeComposeModel(form);
|
||||
return View("SelectCompanies", await BuildSelectionModelAsync(form));
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Send(AdminEmailSendRequest form)
|
||||
{
|
||||
NormalizeComposeModel(form);
|
||||
|
||||
if (!ValidateComposeModel(form))
|
||||
return View("Index", new AdminEmailComposeModel
|
||||
{
|
||||
Subject = form.Subject,
|
||||
BodyHtml = form.BodyHtml
|
||||
});
|
||||
|
||||
if (!ValidateCompanySelection(form))
|
||||
return View("SelectCompanies", await BuildSelectionModelAsync(form));
|
||||
|
||||
var recipients = await LoadRecipientContextsAsync(form.CompanyIds!);
|
||||
var replyToEmail = await GetAdminReplyToAsync();
|
||||
const string replyToName = "Powder Coating Logix Admin";
|
||||
|
||||
var sent = 0;
|
||||
var failed = 0;
|
||||
var skipped = 0;
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
await PopulateViewBag();
|
||||
return View("Index", form);
|
||||
}
|
||||
var renderedSubject = RenderPlainTemplate(form.Subject, recipient);
|
||||
var renderedBody = RenderHtmlTemplate(form.BodyHtml, recipient);
|
||||
var plainTextBody = ConvertHtmlToPlainText(renderedBody);
|
||||
|
||||
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>";
|
||||
if (string.IsNullOrWhiteSpace(recipient.RecipientEmail))
|
||||
{
|
||||
skipped++;
|
||||
await WriteLogAsync(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Email,
|
||||
NotificationType = NotificationType.AdminEmail,
|
||||
Status = NotificationStatus.Skipped,
|
||||
RecipientName = recipient.RecipientName,
|
||||
Recipient = string.Empty,
|
||||
Subject = renderedSubject,
|
||||
Message = plainTextBody,
|
||||
ErrorMessage = "Company primary contact email is not configured.",
|
||||
SentAt = DateTime.UtcNow,
|
||||
CompanyId = recipient.CompanyId
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var wrappedHtml = WrapRenderedHtml(renderedBody);
|
||||
var (success, error) = await _emailService.SendEmailAsync(
|
||||
email, name, form.Subject, form.Body, htmlBody);
|
||||
recipient.RecipientEmail,
|
||||
recipient.RecipientName,
|
||||
renderedSubject,
|
||||
plainTextBody,
|
||||
wrappedHtml,
|
||||
replyToEmail: replyToEmail,
|
||||
replyToName: replyToEmail is null ? null : replyToName);
|
||||
|
||||
if (success) sent++;
|
||||
else
|
||||
else failed++;
|
||||
|
||||
await WriteLogAsync(new NotificationLog
|
||||
{
|
||||
failed++;
|
||||
_logger.LogWarning("Broadcast email failed for {Email}: {Error}", email, error);
|
||||
}
|
||||
Channel = NotificationChannel.Email,
|
||||
NotificationType = NotificationType.AdminEmail,
|
||||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = recipient.RecipientName,
|
||||
Recipient = recipient.RecipientEmail,
|
||||
Subject = renderedSubject,
|
||||
Message = plainTextBody,
|
||||
ErrorMessage = error,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CompanyId = recipient.CompanyId
|
||||
});
|
||||
}
|
||||
|
||||
TempData["Success"] = $"Broadcast sent: {sent} delivered, {failed} failed. Total recipients: {recipients.Count}.";
|
||||
TempData["Success"] = $"Admin email processed for {recipients.Count} selected compan{(recipients.Count == 1 ? "y" : "ies")}: {sent} sent, {failed} failed, {skipped} skipped.";
|
||||
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)
|
||||
private bool ValidateComposeModel(AdminEmailComposeModel form)
|
||||
{
|
||||
var companyQuery = _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted && c.IsActive && !string.IsNullOrEmpty(c.PrimaryContactEmail)
|
||||
&& !c.MarketingEmailOptOut);
|
||||
if (string.IsNullOrWhiteSpace(form.Subject))
|
||||
ModelState.AddModelError(nameof(form.Subject), "Subject is required.");
|
||||
|
||||
target ??= "active";
|
||||
if (string.IsNullOrWhiteSpace(ConvertHtmlToPlainText(form.BodyHtml)))
|
||||
ModelState.AddModelError(nameof(form.BodyHtml), "Message body is required.");
|
||||
|
||||
switch (target)
|
||||
return ModelState.IsValid;
|
||||
}
|
||||
|
||||
private bool ValidateCompanySelection(AdminEmailSelectionModel form)
|
||||
{
|
||||
if (form.CompanyIds == null || form.CompanyIds.Length == 0)
|
||||
ModelState.AddModelError(nameof(form.CompanyIds), "Select at least one company.");
|
||||
|
||||
return ModelState.IsValid;
|
||||
}
|
||||
|
||||
private static void NormalizeComposeModel(AdminEmailComposeModel form)
|
||||
{
|
||||
form.Subject = (form.Subject ?? string.Empty).Trim();
|
||||
form.BodyHtml = SanitizeHtml(form.BodyHtml);
|
||||
}
|
||||
|
||||
private async Task<AdminEmailSelectionModel> BuildSelectionModelAsync(AdminEmailComposeModel form)
|
||||
{
|
||||
var selectedIds = form is AdminEmailSelectionModel selection && selection.CompanyIds != null
|
||||
? selection.CompanyIds
|
||||
: Array.Empty<int>();
|
||||
|
||||
return new AdminEmailSelectionModel
|
||||
{
|
||||
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;
|
||||
Subject = form.Subject,
|
||||
BodyHtml = form.BodyHtml,
|
||||
CompanyIds = selectedIds,
|
||||
AvailableCompanies = await LoadCompanyOptionsAsync(selectedIds)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AdminEmailPreviewModel> BuildPreviewModelAsync(AdminEmailSelectionModel form)
|
||||
{
|
||||
var recipients = await LoadRecipientContextsAsync(form.CompanyIds!);
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
return new AdminEmailPreviewModel
|
||||
{
|
||||
Subject = form.Subject,
|
||||
BodyHtml = form.BodyHtml,
|
||||
CompanyIds = form.CompanyIds,
|
||||
SelectedCompanies = [],
|
||||
EligibleCount = 0,
|
||||
SkippedCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var companies = await companyQuery
|
||||
.Select(c => new { c.PrimaryContactEmail, c.CompanyName, c.MarketingUnsubscribeToken })
|
||||
.ToListAsync();
|
||||
var sampleRecipient = recipients.FirstOrDefault(r => !string.IsNullOrWhiteSpace(r.RecipientEmail))
|
||||
?? recipients.First();
|
||||
|
||||
return companies
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.PrimaryContactEmail))
|
||||
.Select(c => (c.PrimaryContactEmail!, c.CompanyName, c.MarketingUnsubscribeToken))
|
||||
.ToList();
|
||||
var sampleSubject = RenderPlainTemplate(form.Subject, sampleRecipient);
|
||||
var sampleBody = RenderHtmlTemplate(form.BodyHtml, sampleRecipient);
|
||||
|
||||
return new AdminEmailPreviewModel
|
||||
{
|
||||
Subject = form.Subject,
|
||||
BodyHtml = form.BodyHtml,
|
||||
CompanyIds = form.CompanyIds,
|
||||
SelectedCompanies = recipients.Select(r => new AdminEmailRecipientPreviewRow
|
||||
{
|
||||
CompanyId = r.CompanyId,
|
||||
CompanyName = r.CompanyName,
|
||||
RecipientName = r.RecipientName,
|
||||
RecipientEmail = r.RecipientEmail,
|
||||
CompanyAdminName = r.CompanyAdminName,
|
||||
CompanyAdminEmail = r.CompanyAdminEmail,
|
||||
CanSend = !string.IsNullOrWhiteSpace(r.RecipientEmail),
|
||||
SkipReason = string.IsNullOrWhiteSpace(r.RecipientEmail)
|
||||
? "Missing primary contact email"
|
||||
: null
|
||||
}).ToList(),
|
||||
EligibleCount = recipients.Count(r => !string.IsNullOrWhiteSpace(r.RecipientEmail)),
|
||||
SkippedCount = recipients.Count(r => string.IsNullOrWhiteSpace(r.RecipientEmail)),
|
||||
SamplePreview = new AdminEmailRenderedPreview
|
||||
{
|
||||
CompanyName = sampleRecipient.CompanyName,
|
||||
RecipientName = sampleRecipient.RecipientName,
|
||||
RecipientEmail = sampleRecipient.RecipientEmail,
|
||||
RenderedSubject = sampleSubject,
|
||||
RenderedHtmlBody = WrapRenderedHtml(sampleBody)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <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()
|
||||
private async Task<List<AdminEmailCompanyOption>> LoadCompanyOptionsAsync(IReadOnlyCollection<int> selectedIds)
|
||||
{
|
||||
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)
|
||||
var companies = await _db.Companies
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.Select(c => new { c.Id, c.CompanyName })
|
||||
.Select(c => new AdminEmailCompanyOption
|
||||
{
|
||||
CompanyId = c.Id,
|
||||
CompanyName = c.CompanyName,
|
||||
PrimaryContactName = c.PrimaryContactName,
|
||||
PrimaryContactEmail = c.PrimaryContactEmail,
|
||||
IsActive = c.IsActive
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
ViewBag.ActiveCount = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.CountAsync(c => !c.IsDeleted && c.IsActive &&
|
||||
(c.SubscriptionStatus == SubscriptionStatus.Active ||
|
||||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod));
|
||||
var adminLookup = await LoadCompanyAdminLookupAsync(companies.Select(c => c.CompanyId).ToArray());
|
||||
|
||||
foreach (var company in companies)
|
||||
{
|
||||
company.IsSelected = selectedIds.Contains(company.CompanyId);
|
||||
if (adminLookup.TryGetValue(company.CompanyId, out var admin))
|
||||
{
|
||||
company.CompanyAdminName = admin.FullName;
|
||||
company.CompanyAdminEmail = admin.Email;
|
||||
}
|
||||
}
|
||||
|
||||
return companies;
|
||||
}
|
||||
|
||||
private async Task<List<AdminEmailRecipientContext>> LoadRecipientContextsAsync(IReadOnlyCollection<int> companyIds)
|
||||
{
|
||||
var companies = await _db.Companies
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => companyIds.Contains(c.Id) && !c.IsDeleted)
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.CompanyName,
|
||||
c.PrimaryContactName,
|
||||
c.PrimaryContactEmail
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var adminLookup = await LoadCompanyAdminLookupAsync(companies.Select(c => c.Id).ToArray());
|
||||
|
||||
return companies.Select(company =>
|
||||
{
|
||||
adminLookup.TryGetValue(company.Id, out var admin);
|
||||
|
||||
return new AdminEmailRecipientContext
|
||||
{
|
||||
CompanyId = company.Id,
|
||||
CompanyName = company.CompanyName,
|
||||
RecipientName = string.IsNullOrWhiteSpace(company.PrimaryContactName)
|
||||
? company.CompanyName
|
||||
: company.PrimaryContactName,
|
||||
RecipientEmail = company.PrimaryContactEmail,
|
||||
FirstName = ExtractFirstName(company.PrimaryContactName, company.CompanyName),
|
||||
PrimaryContactName = company.PrimaryContactName,
|
||||
CompanyAdminName = admin?.FullName,
|
||||
CompanyAdminEmail = admin?.Email,
|
||||
CompanyAdminFirstName = ExtractFirstName(admin?.FullName, company.CompanyName)
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<int, CompanyAdminLookup>> LoadCompanyAdminLookupAsync(IReadOnlyCollection<int> companyIds)
|
||||
{
|
||||
var admins = await _db.Users
|
||||
.AsNoTracking()
|
||||
.Where(u => companyIds.Contains(u.CompanyId)
|
||||
&& u.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin
|
||||
&& u.IsActive)
|
||||
.OrderBy(u => u.CreatedAt)
|
||||
.Select(u => new
|
||||
{
|
||||
u.CompanyId,
|
||||
u.FirstName,
|
||||
u.LastName,
|
||||
u.Email
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return admins
|
||||
.GroupBy(u => u.CompanyId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g =>
|
||||
{
|
||||
var admin = g.First();
|
||||
return new CompanyAdminLookup
|
||||
{
|
||||
FullName = $"{admin.FirstName} {admin.LastName}".Trim(),
|
||||
Email = admin.Email ?? string.Empty
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string?> GetAdminReplyToAsync()
|
||||
{
|
||||
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.AdminNotificationEmail);
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
|
||||
return raw
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.FirstOrDefault(email => email.Contains('@'));
|
||||
}
|
||||
|
||||
private async Task WriteLogAsync(NotificationLog log)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _db.NotificationLogs.AddAsync(log);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to write admin email notification log for company {CompanyId}", log.CompanyId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string RenderPlainTemplate(string template, AdminEmailRecipientContext recipient)
|
||||
{
|
||||
var rendered = template ?? string.Empty;
|
||||
foreach (var replacement in BuildReplacementDictionary(recipient))
|
||||
rendered = rendered.Replace(replacement.Key, replacement.Value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return rendered.Trim();
|
||||
}
|
||||
|
||||
private static string RenderHtmlTemplate(string templateHtml, AdminEmailRecipientContext recipient)
|
||||
{
|
||||
var rendered = templateHtml ?? string.Empty;
|
||||
foreach (var replacement in BuildReplacementDictionary(recipient))
|
||||
rendered = rendered.Replace(
|
||||
replacement.Key,
|
||||
WebUtility.HtmlEncode(replacement.Value),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildReplacementDictionary(AdminEmailRecipientContext recipient) => new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["{{FirstName}}"] = recipient.FirstName,
|
||||
["{{FullName}}"] = recipient.RecipientName,
|
||||
["{{CompanyName}}"] = recipient.CompanyName,
|
||||
["{{PrimaryContactName}}"] = recipient.PrimaryContactName ?? recipient.RecipientName,
|
||||
["{{PrimaryContactEmail}}"] = recipient.RecipientEmail ?? string.Empty,
|
||||
["{{CompanyAdminFirstName}}"] = recipient.CompanyAdminFirstName ?? string.Empty,
|
||||
["{{CompanyAdminName}}"] = recipient.CompanyAdminName ?? string.Empty,
|
||||
["{{CompanyAdminEmail}}"] = recipient.CompanyAdminEmail ?? string.Empty
|
||||
};
|
||||
|
||||
private static string WrapRenderedHtml(string renderedHtmlBody)
|
||||
{
|
||||
return $"""
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;max-width:700px;margin:0 auto;color:#1f2937;line-height:1.6;">
|
||||
{renderedHtmlBody}
|
||||
</div>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string SanitizeHtml(string? html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return string.Empty;
|
||||
|
||||
var sanitized = CommentRegex.Replace(html, string.Empty);
|
||||
sanitized = ScriptRegex.Replace(sanitized, string.Empty);
|
||||
sanitized = sanitized.Replace("<div", "<p", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("</div>", "</p>", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
sanitized = TagRegex.Replace(sanitized, match =>
|
||||
{
|
||||
var isClosingTag = match.Groups[1].Value == "/";
|
||||
var tagName = match.Groups[2].Value.ToLowerInvariant();
|
||||
var attributes = match.Groups[3].Value;
|
||||
|
||||
if (!AllowedTags.Contains(tagName))
|
||||
return string.Empty;
|
||||
|
||||
if (tagName == "br")
|
||||
return "<br>";
|
||||
|
||||
if (tagName == "a")
|
||||
{
|
||||
if (isClosingTag)
|
||||
return "</a>";
|
||||
|
||||
var href = HrefRegex.Match(attributes);
|
||||
var hrefValue = href.Success ? href.Groups[2].Value : string.Empty;
|
||||
if (!IsSafeHref(hrefValue))
|
||||
return string.Empty;
|
||||
|
||||
var encodedHref = WebUtility.HtmlEncode(hrefValue);
|
||||
return $"""<a href="{encodedHref}" target="_blank" rel="noopener noreferrer">""";
|
||||
}
|
||||
|
||||
return isClosingTag ? $"</{tagName}>" : $"<{tagName}>";
|
||||
});
|
||||
|
||||
return sanitized.Trim();
|
||||
}
|
||||
|
||||
private static bool IsSafeHref(string? href)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(href))
|
||||
return false;
|
||||
|
||||
return href.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||
|| href.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| href.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ConvertHtmlToPlainText(string? html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return string.Empty;
|
||||
|
||||
var plain = html;
|
||||
plain = Regex.Replace(plain, @"<\s*br\s*/?\s*>", "\n", RegexOptions.IgnoreCase);
|
||||
plain = Regex.Replace(plain, @"<\s*/\s*(p|h1|h2|h3|h4|blockquote)\s*>", "\n\n", RegexOptions.IgnoreCase);
|
||||
plain = Regex.Replace(plain, @"<\s*li\s*>", "- ", RegexOptions.IgnoreCase);
|
||||
plain = Regex.Replace(plain, @"<\s*/\s*li\s*>", "\n", RegexOptions.IgnoreCase);
|
||||
plain = Regex.Replace(plain, @"<[^>]+>", string.Empty);
|
||||
plain = WebUtility.HtmlDecode(plain);
|
||||
plain = Regex.Replace(plain, @"\n{3,}", "\n\n");
|
||||
return plain.Trim();
|
||||
}
|
||||
|
||||
private static string ExtractFirstName(string? fullName, string fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullName))
|
||||
return fallback;
|
||||
|
||||
var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length == 0 ? fallback : parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
public class BroadcastForm
|
||||
public class AdminEmailComposeModel
|
||||
{
|
||||
public string Target { get; set; } = "active";
|
||||
public string? PlanFilter { get; set; }
|
||||
public int[]? CompanyIds { get; set; }
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
[Required]
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
[Required]
|
||||
public string BodyHtml { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AdminEmailSelectionModel : AdminEmailComposeModel
|
||||
{
|
||||
public int[]? CompanyIds { get; set; }
|
||||
public List<AdminEmailCompanyOption> AvailableCompanies { get; set; } = [];
|
||||
}
|
||||
|
||||
public class AdminEmailSendRequest : AdminEmailSelectionModel;
|
||||
|
||||
public class AdminEmailPreviewModel : AdminEmailSendRequest
|
||||
{
|
||||
public List<AdminEmailRecipientPreviewRow> SelectedCompanies { get; set; } = [];
|
||||
public int EligibleCount { get; set; }
|
||||
public int SkippedCount { get; set; }
|
||||
public AdminEmailRenderedPreview SamplePreview { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AdminEmailCompanyOption
|
||||
{
|
||||
public int CompanyId { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string? PrimaryContactName { get; set; }
|
||||
public string? PrimaryContactEmail { get; set; }
|
||||
public string? CompanyAdminName { get; set; }
|
||||
public string? CompanyAdminEmail { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsSelected { get; set; }
|
||||
}
|
||||
|
||||
public class AdminEmailRecipientPreviewRow
|
||||
{
|
||||
public int CompanyId { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string RecipientName { get; set; } = string.Empty;
|
||||
public string? RecipientEmail { get; set; }
|
||||
public string? CompanyAdminName { get; set; }
|
||||
public string? CompanyAdminEmail { get; set; }
|
||||
public bool CanSend { get; set; }
|
||||
public string? SkipReason { get; set; }
|
||||
}
|
||||
|
||||
public class AdminEmailRenderedPreview
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string RecipientName { get; set; } = string.Empty;
|
||||
public string? RecipientEmail { get; set; }
|
||||
public string RenderedSubject { get; set; } = string.Empty;
|
||||
public string RenderedHtmlBody { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdminEmailRecipientContext
|
||||
{
|
||||
public int CompanyId { get; init; }
|
||||
public string CompanyName { get; init; } = string.Empty;
|
||||
public string RecipientName { get; init; } = string.Empty;
|
||||
public string? RecipientEmail { get; init; }
|
||||
public string FirstName { get; init; } = string.Empty;
|
||||
public string? PrimaryContactName { get; init; }
|
||||
public string? CompanyAdminFirstName { get; init; }
|
||||
public string? CompanyAdminName { get; init; }
|
||||
public string? CompanyAdminEmail { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class CompanyAdminLookup
|
||||
{
|
||||
public string FullName { get; init; } = string.Empty;
|
||||
public string Email { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -1,172 +1,173 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model BroadcastForm
|
||||
@model AdminEmailComposeModel
|
||||
@{
|
||||
ViewData["Title"] = "Email Broadcast";
|
||||
ViewData["Title"] = "Admin Email";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
[data-bs-theme="dark"] .card {
|
||||
border-color: var(--bs-border-color) !important;
|
||||
.admin-email-shell {
|
||||
max-width: 1100px;
|
||||
}
|
||||
[data-bs-theme="dark"] .card-header {
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
color: var(--bs-body-color);
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .5rem;
|
||||
padding: .75rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-bottom: 0;
|
||||
border-top-left-radius: .75rem;
|
||||
border-top-right-radius: .75rem;
|
||||
background: linear-gradient(135deg, rgba(13,110,253,.08), rgba(25,135,84,.08));
|
||||
}
|
||||
[data-bs-theme="dark"] .alert-info {
|
||||
background-color: rgba(13,202,240,.1);
|
||||
border-color: rgba(13,202,240,.3);
|
||||
color: var(--bs-body-color);
|
||||
|
||||
.editor-surface {
|
||||
min-height: 320px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-bottom-left-radius: .75rem;
|
||||
border-bottom-right-radius: .75rem;
|
||||
background: var(--bs-body-bg);
|
||||
overflow: auto;
|
||||
}
|
||||
[data-bs-theme="dark"] .alert-warning {
|
||||
background-color: rgba(255,193,7,.1);
|
||||
border-color: rgba(255,193,7,.3);
|
||||
color: var(--bs-body-color);
|
||||
|
||||
.editor-surface:focus {
|
||||
outline: 0;
|
||||
box-shadow: inset 0 0 0 1px rgba(13,110,253,.45);
|
||||
}
|
||||
|
||||
.token-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: .35rem .6rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(13,110,253,.08);
|
||||
color: var(--bs-primary);
|
||||
font-size: .875rem;
|
||||
font-family: var(--bs-font-monospace);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3" style="max-width:860px">
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<h4 class="mb-0"><i class="bi bi-broadcast me-2 text-primary"></i>Email Broadcast</h4>
|
||||
<div class="container-fluid py-4 admin-email-shell">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
||||
<div>
|
||||
<h3 class="mb-1"><i class="bi bi-envelope-paper me-2 text-primary"></i>Admin Email Wizard</h3>
|
||||
<p class="text-muted mb-0">Step 1 of 3: write the subject and rich-text message.</p>
|
||||
</div>
|
||||
<div class="badge text-bg-primary-subtle border border-primary-subtle px-3 py-2">Super Admin Only</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent mb-3">@TempData["Success"]</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent mb-3">@TempData["Error"]</div>
|
||||
<div class="alert alert-success alert-permanent mb-4">@TempData["Success"]</div>
|
||||
}
|
||||
|
||||
<form method="post" asp-action="Send" id="broadcast-form">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<form method="post" asp-action="SelectCompanies" id="compose-form">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="row g-3">
|
||||
@* Left: recipients *@
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header fw-semibold py-2">Recipients</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Send to</label>
|
||||
<select name="Target" id="target-select" class="form-select" onchange="onTargetChange()">
|
||||
<option value="active" selected="@(Model.Target == "active")">All active companies</option>
|
||||
<option value="all" selected="@(Model.Target == "all")">All companies (incl. expired)</option>
|
||||
<option value="status_grace" selected="@(Model.Target == "status_grace")">Grace period companies</option>
|
||||
<option value="status_expired" selected="@(Model.Target == "status_expired")">Expired companies</option>
|
||||
<option value="plan" selected="@(Model.Target == "plan")">By subscription plan</option>
|
||||
<option value="specific" selected="@(Model.Target == "specific")">Specific companies</option>
|
||||
</select>
|
||||
<div class="mb-4">
|
||||
<label asp-for="Subject" class="form-label fw-semibold">Subject</label>
|
||||
<input asp-for="Subject" class="form-control form-control-lg" placeholder="Service update for {{CompanyName}}" />
|
||||
<span asp-validation-for="Subject" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Message</label>
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="bold"><i class="bi bi-type-bold"></i></button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="italic"><i class="bi bi-type-italic"></i></button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="underline"><i class="bi bi-type-underline"></i></button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="insertUnorderedList"><i class="bi bi-list-ul"></i></button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="insertOrderedList"><i class="bi bi-list-ol"></i></button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="add-link-btn"><i class="bi bi-link-45deg"></i></button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="removeFormat">Clear Formatting</button>
|
||||
</div>
|
||||
<div id="message-editor" class="editor-surface" contenteditable="true">@Html.Raw(Model.BodyHtml)</div>
|
||||
<textarea asp-for="BodyHtml" class="d-none"></textarea>
|
||||
<span asp-validation-for="BodyHtml" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 align-items-start mb-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="alert alert-info mb-0">
|
||||
The email sends one company at a time to that company's <strong>Primary Contact Email</strong>.
|
||||
Rich text is supported, and the preview step will render one merged sample before anything sends.
|
||||
</div>
|
||||
|
||||
<div id="plan-filter" class="mb-3" style="display:none">
|
||||
<label class="form-label fw-medium">Plan</label>
|
||||
<select name="PlanFilter" class="form-select">
|
||||
@foreach (var p in (IEnumerable<dynamic>)ViewBag.PlanConfigs)
|
||||
{
|
||||
<option value="@p.Plan">@p.DisplayName</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="specific-filter" class="mb-3" style="display:none">
|
||||
<label class="form-label fw-medium">Companies</label>
|
||||
<select name="CompanyIds" multiple class="form-select" style="height:160px">
|
||||
@foreach (var c in (IEnumerable<dynamic>)ViewBag.Companies)
|
||||
{
|
||||
<option value="@c.Id">@c.CompanyName</option>
|
||||
}
|
||||
</select>
|
||||
<div class="form-text">Hold Ctrl / Cmd to select multiple.</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info py-2 small mb-0" id="recipient-preview">
|
||||
<span id="recipient-count">@ViewBag.ActiveCount</span> company email(s) will receive this message.
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 bg-light h-100">
|
||||
<div class="card-body">
|
||||
<div class="fw-semibold mb-2">Available Merge Tokens</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="token-pill border-0" data-token="{{FirstName}}">{{FirstName}}</button>
|
||||
<button type="button" class="token-pill border-0" data-token="{{FullName}}">{{FullName}}</button>
|
||||
<button type="button" class="token-pill border-0" data-token="{{CompanyName}}">{{CompanyName}}</button>
|
||||
<button type="button" class="token-pill border-0" data-token="{{PrimaryContactName}}">{{PrimaryContactName}}</button>
|
||||
<button type="button" class="token-pill border-0" data-token="{{PrimaryContactEmail}}">{{PrimaryContactEmail}}</button>
|
||||
<button type="button" class="token-pill border-0" data-token="{{CompanyAdminFirstName}}">{{CompanyAdminFirstName}}</button>
|
||||
<button type="button" class="token-pill border-0" data-token="{{CompanyAdminName}}">{{CompanyAdminName}}</button>
|
||||
<button type="button" class="token-pill border-0" data-token="{{CompanyAdminEmail}}">{{CompanyAdminEmail}}</button>
|
||||
</div>
|
||||
<div class="small text-muted mt-2">Click a token to insert it into the editor.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Right: compose *@
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold py-2">Compose</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label asp-for="Subject" class="form-label fw-medium">Subject</label>
|
||||
<input asp-for="Subject" class="form-control" placeholder="e.g. Scheduled maintenance this Saturday" />
|
||||
<span asp-validation-for="Subject" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Body" class="form-label fw-medium">Message</label>
|
||||
<textarea asp-for="Body" class="form-control" rows="12"
|
||||
placeholder="Write your message here. Plain text — line breaks will be preserved."></textarea>
|
||||
<span asp-validation-for="Body" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="alert alert-warning py-2 small mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
This will send a real email to the primary contact address of each matching company. Double-check your recipient selection before sending.
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="send-btn">
|
||||
<i class="bi bi-send me-1"></i>Send Broadcast
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
Next: Choose Companies <i class="bi bi-arrow-right ms-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script>
|
||||
(function () {
|
||||
const targetSelect = document.getElementById('target-select');
|
||||
const planFilter = document.getElementById('plan-filter');
|
||||
const specificFilter = document.getElementById('specific-filter');
|
||||
const countEl = document.getElementById('recipient-count');
|
||||
(function () {
|
||||
const form = document.getElementById('compose-form');
|
||||
const editor = document.getElementById('message-editor');
|
||||
const hiddenBody = document.getElementById('BodyHtml');
|
||||
|
||||
function onTargetChange() {
|
||||
const val = targetSelect.value;
|
||||
planFilter.style.display = val === 'plan' ? '' : 'none';
|
||||
specificFilter.style.display = val === 'specific' ? '' : 'none';
|
||||
updateCount();
|
||||
}
|
||||
|
||||
async function updateCount() {
|
||||
const val = targetSelect.value;
|
||||
const planSel = document.querySelector('[name="PlanFilter"]');
|
||||
const companySel = document.querySelector('[name="CompanyIds"]');
|
||||
|
||||
const params = new URLSearchParams({ target: val });
|
||||
if (val === 'plan' && planSel) params.set('plan', planSel.value);
|
||||
if (val === 'specific' && companySel) {
|
||||
Array.from(companySel.selectedOptions).forEach(o => params.append('companyIds', o.value));
|
||||
function syncEditor() {
|
||||
hiddenBody.value = editor.innerHTML.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('@Url.Action("RecipientCount")?' + params.toString());
|
||||
const data = await resp.json();
|
||||
countEl.textContent = data.count;
|
||||
} catch { countEl.textContent = '?'; }
|
||||
}
|
||||
document.querySelectorAll('[data-editor-command]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
document.execCommand(button.dataset.editorCommand, false);
|
||||
editor.focus();
|
||||
syncEditor();
|
||||
});
|
||||
});
|
||||
|
||||
window.onTargetChange = onTargetChange;
|
||||
document.getElementById('add-link-btn').addEventListener('click', () => {
|
||||
const url = prompt('Enter a full URL starting with https:// or mailto:');
|
||||
if (!url) return;
|
||||
document.execCommand('createLink', false, url);
|
||||
editor.focus();
|
||||
syncEditor();
|
||||
});
|
||||
|
||||
// Wire up change events for sub-filters
|
||||
document.querySelector('[name="PlanFilter"]')?.addEventListener('change', updateCount);
|
||||
document.querySelector('[name="CompanyIds"]')?.addEventListener('change', updateCount);
|
||||
document.querySelectorAll('[data-token]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
editor.focus();
|
||||
document.execCommand('insertText', false, button.dataset.token);
|
||||
syncEditor();
|
||||
});
|
||||
});
|
||||
|
||||
// Init visibility
|
||||
onTargetChange();
|
||||
|
||||
// Confirm before send
|
||||
document.getElementById('send-btn').addEventListener('click', function (e) {
|
||||
const count = countEl.textContent;
|
||||
if (!confirm(`Send this email to ${count} company recipient(s)?`)) e.preventDefault();
|
||||
});
|
||||
})();
|
||||
editor.addEventListener('input', syncEditor);
|
||||
form.addEventListener('submit', syncEditor);
|
||||
syncEditor();
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model AdminEmailPreviewModel
|
||||
@{
|
||||
ViewData["Title"] = "Preview Admin Email";
|
||||
}
|
||||
|
||||
<div class="container-fluid py-4" style="max-width:1150px">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
||||
<div>
|
||||
<h3 class="mb-1"><i class="bi bi-eye me-2 text-primary"></i>Admin Email Wizard</h3>
|
||||
<p class="text-muted mb-0">Step 3 of 3: preview one merged sample, then send sequentially.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<span class="badge text-bg-success">@Model.EligibleCount ready to send</span>
|
||||
@if (Model.SkippedCount > 0)
|
||||
{
|
||||
<span class="badge text-bg-warning">@Model.SkippedCount missing email</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-header bg-transparent fw-semibold py-3">Sample Preview</div>
|
||||
<div class="card-body">
|
||||
<div class="small text-muted text-uppercase fw-semibold mb-1">Recipient Sample</div>
|
||||
<div class="mb-3">
|
||||
<div class="fw-semibold">@Model.SamplePreview.RecipientName</div>
|
||||
<div class="text-muted">@Model.SamplePreview.RecipientEmail</div>
|
||||
<div class="small text-muted">@Model.SamplePreview.CompanyName</div>
|
||||
</div>
|
||||
|
||||
<div class="small text-muted text-uppercase fw-semibold mb-1">Rendered Subject</div>
|
||||
<div class="fw-semibold mb-3">@Model.SamplePreview.RenderedSubject</div>
|
||||
|
||||
<div class="small text-muted text-uppercase fw-semibold mb-2">Rendered HTML Body</div>
|
||||
<div class="border rounded-3 p-3 bg-light-subtle" style="min-height:320px">
|
||||
@Html.Raw(Model.SamplePreview.RenderedHtmlBody)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-transparent fw-semibold py-3">Delivery Summary</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-0">
|
||||
The system will process each selected company one at a time.
|
||||
The sample shown on the left uses the first available recipient after token replacement.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-transparent fw-semibold py-3">Selected Companies</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>Recipient</th>
|
||||
<th>Company Admin</th>
|
||||
<th>Ready</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in Model.SelectedCompanies)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold">@row.CompanyName</div>
|
||||
<div class="small text-muted">#@row.CompanyId</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>@row.RecipientName</div>
|
||||
<div class="small text-muted">@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)</div>
|
||||
@if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail))
|
||||
{
|
||||
<div class="small text-muted">@row.CompanyAdminEmail</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (row.CanSend)
|
||||
{
|
||||
<span class="badge text-bg-success">Ready</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge text-bg-warning">@row.SkipReason</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" asp-action="Send" class="mt-4">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" asp-for="Subject" />
|
||||
<input type="hidden" asp-for="BodyHtml" />
|
||||
@foreach (var companyId in Model.CompanyIds ?? Array.Empty<int>())
|
||||
{
|
||||
<input type="hidden" name="CompanyIds" value="@companyId" />
|
||||
}
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-between gap-3">
|
||||
<button type="submit"
|
||||
formaction="@Url.Action("BackToSelectCompanies")"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Back to Companies
|
||||
</button>
|
||||
|
||||
@if (Model.EligibleCount > 0)
|
||||
{
|
||||
<button type="submit" class="btn btn-primary"
|
||||
onclick="return confirm('Send this message one company at a time to @Model.EligibleCount ready recipient(s)?');">
|
||||
<i class="bi bi-send me-2"></i>Send Admin Email
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" disabled>No deliverable recipients selected</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,170 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model AdminEmailSelectionModel
|
||||
@{
|
||||
ViewData["Title"] = "Choose Companies";
|
||||
}
|
||||
|
||||
<div class="container-fluid py-4" style="max-width:1100px">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
||||
<div>
|
||||
<h3 class="mb-1"><i class="bi bi-building-check me-2 text-primary"></i>Admin Email Wizard</h3>
|
||||
<p class="text-muted mb-0">Step 2 of 3: choose which companies should receive this message.</p>
|
||||
</div>
|
||||
<div class="badge text-bg-secondary px-3 py-2">@Model.AvailableCompanies.Count company records</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6">
|
||||
<div class="small text-muted text-uppercase fw-semibold mb-1">Subject</div>
|
||||
<div class="fw-semibold">@Model.Subject</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="small text-muted text-uppercase fw-semibold mb-1">Message Summary</div>
|
||||
<div class="text-muted">Rich-text message prepared. Merge tokens will render on the preview step.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" asp-action="Preview" id="company-select-form">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" asp-for="Subject" />
|
||||
<input type="hidden" asp-for="BodyHtml" />
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 align-items-center mb-3">
|
||||
<div class="col-lg-5">
|
||||
<input type="search" class="form-control" id="company-filter" placeholder="Search company, contact, or email" />
|
||||
</div>
|
||||
<div class="col-lg-7 d-flex flex-wrap gap-2 justify-content-lg-end">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="select-all-btn">Select All Visible</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="clear-all-btn">Clear Visible</button>
|
||||
<span class="badge text-bg-primary" id="selected-count">0 selected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span asp-validation-for="CompanyIds" class="text-danger small d-block mb-3"></span>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:56px"></th>
|
||||
<th>Company</th>
|
||||
<th>Primary Contact</th>
|
||||
<th>Email</th>
|
||||
<th>Company Admin</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var company in Model.AvailableCompanies)
|
||||
{
|
||||
<tr class="company-row">
|
||||
<td>
|
||||
<input class="form-check-input company-checkbox"
|
||||
type="checkbox"
|
||||
name="CompanyIds"
|
||||
value="@company.CompanyId"
|
||||
@(company.IsSelected ? "checked" : null) />
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">@company.CompanyName</div>
|
||||
<div class="small text-muted">#@company.CompanyId</div>
|
||||
</td>
|
||||
<td>@(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "—" : company.PrimaryContactName)</td>
|
||||
<td>
|
||||
@if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail))
|
||||
{
|
||||
<span class="badge text-bg-warning">Missing</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@company.PrimaryContactEmail</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div>@(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "—" : company.CompanyAdminName)</div>
|
||||
@if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail))
|
||||
{
|
||||
<div class="small text-muted">@company.CompanyAdminEmail</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (company.IsActive)
|
||||
{
|
||||
<span class="badge text-bg-success">Active</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge text-bg-secondary">Inactive</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-between gap-3 mt-4">
|
||||
<button type="submit"
|
||||
formaction="@Url.Action("BackToCompose")"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Back to Compose
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Next: Preview <i class="bi bi-arrow-right ms-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script>
|
||||
(function () {
|
||||
const filterInput = document.getElementById('company-filter');
|
||||
const rows = Array.from(document.querySelectorAll('.company-row'));
|
||||
const checkboxes = Array.from(document.querySelectorAll('.company-checkbox'));
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
|
||||
function updateSelectedCount() {
|
||||
const total = checkboxes.filter(cb => cb.checked).length;
|
||||
selectedCount.textContent = `${total} selected`;
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
const term = filterInput.value.trim().toLowerCase();
|
||||
rows.forEach(row => {
|
||||
const visible = row.textContent.toLowerCase().includes(term);
|
||||
row.style.display = visible ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('select-all-btn').addEventListener('click', () => {
|
||||
rows.forEach(row => {
|
||||
if (row.style.display === 'none') return;
|
||||
row.querySelector('.company-checkbox').checked = true;
|
||||
});
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
document.getElementById('clear-all-btn').addEventListener('click', () => {
|
||||
rows.forEach(row => {
|
||||
if (row.style.display === 'none') return;
|
||||
row.querySelector('.company-checkbox').checked = false;
|
||||
});
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
filterInput.addEventListener('input', applyFilter);
|
||||
checkboxes.forEach(cb => cb.addEventListener('change', updateSelectedCount));
|
||||
updateSelectedCount();
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
Reference in New Issue
Block a user