feat(sms): NotificationListForm adapter-gated Type selector + per-type recipients (S7)

This commit is contained in:
Joseph Doherty
2026-06-19 10:38:29 -04:00
parent 4555a3f333
commit f0c69aad83
6 changed files with 373 additions and 15 deletions
@@ -1,10 +1,13 @@
@page "/notifications/lists/create"
@page "/notifications/lists/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject INotificationRepository NotificationRepository
@inject INotificationChannelCatalog ChannelCatalog
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
@@ -25,6 +28,19 @@
<input type="text" class="form-control" @bind="_name" />
</div>
@* Type is create-only: a list's channel fixes which contact field its
recipients carry (email vs phone), so changing it after recipients exist
would orphan them. Disabled on edit (Id.HasValue). *@
<div class="mb-3">
<label class="form-label">Type</label>
<select class="form-select" @bind="_type" disabled="@Id.HasValue">
@foreach (var channel in ChannelCatalog.SupportedChannels)
{
<option value="@channel">@ChannelLabel(channel)</option>
}
</select>
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
@@ -47,10 +63,23 @@
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_recipientName" />
</div>
<div class="mb-2">
<label class="form-label">Email</label>
<input type="email" class="form-control" @bind="_recipientEmail" />
</div>
@* Per-type contact field: email lists collect an email address; SMS
lists collect an E.164 phone number. *@
@if (_type == NotificationType.Sms)
{
<div class="mb-2">
<label class="form-label">Phone</label>
<input type="tel" class="form-control" placeholder="+15551234567"
@bind="_recipientPhone" />
</div>
}
else
{
<div class="mb-2">
<label class="form-label">Email</label>
<input type="email" class="form-control" @bind="_recipientEmail" />
</div>
}
@if (_recipientFormError != null)
{
<div class="text-danger small mt-2">@_recipientFormError</div>
@@ -65,7 +94,7 @@
<thead class="table-light">
<tr>
<th>Name</th>
<th>Email</th>
<th>@(_type == NotificationType.Sms ? "Phone" : "Email")</th>
<th style="width:80px;">Actions</th>
</tr>
</thead>
@@ -80,7 +109,7 @@
{
<tr>
<td>@r.Name</td>
<td>@r.EmailAddress</td>
<td>@(_type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress)</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteRecipient(r)">Delete</button>
</td>
@@ -98,17 +127,29 @@
private bool _loading = true;
private string _name = "";
// Delivery channel for the list. Selectable only on create (an empty Id); on edit it
// reflects the persisted list's Type and the selector is disabled. Defaults to the
// first supported channel so the create form never starts on an undeliverable type.
private NotificationType _type = NotificationType.Email;
private string? _formError;
private NotificationList? _existing;
// Recipients
private List<NotificationRecipient> _recipients = new();
private string _recipientName = "", _recipientEmail = "";
private string _recipientName = "", _recipientEmail = "", _recipientPhone = "";
private string? _recipientFormError;
protected override async Task OnInitializedAsync()
{
// Default the create-form selection to the first supported channel (catalog is
// never empty on a correctly configured central node).
if (!Id.HasValue && ChannelCatalog.SupportedChannels.Count > 0)
{
_type = ChannelCatalog.SupportedChannels[0];
}
if (Id.HasValue)
{
try
@@ -117,6 +158,7 @@
if (_existing != null)
{
_name = _existing.Name;
_type = _existing.Type;
}
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
}
@@ -125,6 +167,12 @@
_loading = false;
}
private static string ChannelLabel(NotificationType type) => type switch
{
NotificationType.Sms => "SMS",
_ => "Email"
};
private async Task Save()
{
_formError = null;
@@ -143,7 +191,10 @@
}
else
{
var nl = new NotificationList(_name.Trim());
// Type is set at create time and never changes thereafter (the selector
// is disabled on edit) so a list's recipients always carry the matching
// contact field.
var nl = new NotificationList(_name.Trim()) { Type = _type };
await NotificationRepository.AddNotificationListAsync(nl);
}
await NotificationRepository.SaveChangesAsync();
@@ -155,26 +206,59 @@
private async Task SaveRecipient()
{
_recipientFormError = null;
if (string.IsNullOrWhiteSpace(_recipientName) || string.IsNullOrWhiteSpace(_recipientEmail))
if (string.IsNullOrWhiteSpace(_recipientName))
{
_recipientFormError = "Name and email required.";
_recipientFormError = "Name required.";
return;
}
NotificationRecipient r;
if (_type == NotificationType.Sms)
{
var phone = _recipientPhone.Trim();
if (!IsValidPhone(phone))
{
_recipientFormError = "A valid phone number is required (digits, optional leading +).";
return;
}
r = NotificationRecipient.ForSms(_recipientName.Trim(), phone);
}
else
{
var email = _recipientEmail.Trim();
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
{
_recipientFormError = "A valid email address is required.";
return;
}
r = NotificationRecipient.ForEmail(_recipientName.Trim(), email);
}
r.NotificationListId = Id!.Value;
try
{
var r = new NotificationRecipient(_recipientName.Trim(), _recipientEmail.Trim())
{
NotificationListId = Id!.Value
};
await NotificationRepository.AddRecipientAsync(r);
await NotificationRepository.SaveChangesAsync();
_recipientName = _recipientEmail = string.Empty;
_recipientName = _recipientEmail = _recipientPhone = string.Empty;
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
}
catch (Exception ex) { _recipientFormError = ex.Message; }
}
// E.164-ish: non-empty, an optional leading '+' then digits only. Kept permissive
// (matches the SMS adapter's recipient handling) rather than enforcing a strict
// regional format or length.
private static bool IsValidPhone(string phone)
{
if (string.IsNullOrWhiteSpace(phone))
{
return false;
}
var digits = phone.StartsWith('+') ? phone[1..] : phone;
return digits.Length > 0 && digits.All(char.IsDigit);
}
private async Task DeleteRecipient(NotificationRecipient r)
{
try
@@ -148,6 +148,14 @@ public static class ServiceCollectionExtensions
// + base-changed staleness banner; never mutates rows, not on the deploy path.
services.AddScoped<ITemplateInheritanceQueryService, TemplateInheritanceQueryService>();
// SMS Notifications (S7): the NotificationListForm Type selector offers only the
// notification channels that actually have a registered delivery adapter. The
// catalog projects the registered INotificationDeliveryAdapter set (Email + SMS,
// registered scoped by AddNotificationOutbox into this same container) to its
// distinct NotificationType set — no hardcoded {Email, Sms}. Scoped because the
// adapters it enumerates are scoped (they hold a scoped INotificationRepository).
services.AddScoped<INotificationChannelCatalog, NotificationChannelCatalog>();
// Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200);
@@ -0,0 +1,21 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Exposes the set of notification delivery channels that actually have a registered
/// delivery adapter, so the Central UI never offers a notification-list Type that the
/// central node cannot deliver. The list is derived from the registered
/// <see cref="ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery.INotificationDeliveryAdapter"/>
/// set (each adapter handles a single <see cref="NotificationType"/>) rather than from
/// the <see cref="NotificationType"/> enum, so adding/removing an adapter changes the UI
/// options automatically.
/// </summary>
public interface INotificationChannelCatalog
{
/// <summary>
/// The distinct notification channels that have a registered delivery adapter,
/// in a stable order. Never empty in a correctly configured central node.
/// </summary>
IReadOnlyList<NotificationType> SupportedChannels { get; }
}
@@ -0,0 +1,36 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Default <see cref="INotificationChannelCatalog"/> that derives the supported channels
/// from the registered <see cref="INotificationDeliveryAdapter"/> set. The central Host
/// registers the Email + SMS adapters (see <c>AddNotificationOutbox</c>) into the same DI
/// container that hosts the Central UI, so projecting each adapter's
/// <see cref="INotificationDeliveryAdapter.Type"/> yields exactly the channels the central
/// node can actually deliver — no hardcoded <c>{Email, Sms}</c> list to drift.
/// </summary>
public sealed class NotificationChannelCatalog : INotificationChannelCatalog
{
/// <summary>
/// Initializes the catalog from the registered delivery adapters, snapshotting the
/// distinct channel set once (the adapter registrations are fixed for the process
/// lifetime). Ordering follows the <see cref="NotificationType"/> enum so the UI Type
/// selector renders in a stable order regardless of adapter registration order.
/// </summary>
/// <param name="adapters">The registered delivery adapters (one per channel).</param>
public NotificationChannelCatalog(IEnumerable<INotificationDeliveryAdapter> adapters)
{
ArgumentNullException.ThrowIfNull(adapters);
SupportedChannels = adapters
.Select(a => a.Type)
.Distinct()
.OrderBy(t => (int)t)
.ToArray();
}
/// <inheritdoc />
public IReadOnlyList<NotificationType> SupportedChannels { get; }
}
@@ -28,6 +28,11 @@
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
<!-- SMS Notifications (S7): the NotificationListForm Type selector derives its options
from the registered INotificationDeliveryAdapter set (via INotificationChannelCatalog)
so it reflects the channels the central node can actually deliver — never a hardcoded
{Email, Sms}. The adapters live in NotificationOutbox; no NuGet packages added. -->
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />