diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationListForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationListForm.razor
index dc6df304..cd6c9362 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationListForm.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationListForm.razor
@@ -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
@@ -25,6 +28,19 @@
+ @* 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). *@
+
+ Type
+
+ @foreach (var channel in ChannelCatalog.SupportedChannels)
+ {
+ @ChannelLabel(channel)
+ }
+
+
+
@if (_formError != null)
{
@_formError
@@ -47,10 +63,23 @@
Name
-
- Email
-
-
+ @* Per-type contact field: email lists collect an email address; SMS
+ lists collect an E.164 phone number. *@
+ @if (_type == NotificationType.Sms)
+ {
+
+ Phone
+
+
+ }
+ else
+ {
+
+ Email
+
+
+ }
@if (_recipientFormError != null)
{
@_recipientFormError
@@ -65,7 +94,7 @@
Name
- Email
+ @(_type == NotificationType.Sms ? "Phone" : "Email")
Actions
@@ -80,7 +109,7 @@
{
@r.Name
- @r.EmailAddress
+ @(_type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress)
DeleteRecipient(r)">Delete
@@ -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 _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
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
index 0f0de4da..3892066b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
@@ -148,6 +148,14 @@ public static class ServiceCollectionExtensions
// + base-changed staleness banner; never mutates rows, not on the deploy path.
services.AddScoped();
+ // 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();
+
// Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200);
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/INotificationChannelCatalog.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/INotificationChannelCatalog.cs
new file mode 100644
index 00000000..7eff9f3e
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/INotificationChannelCatalog.cs
@@ -0,0 +1,21 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+
+///
+/// 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
+///
+/// set (each adapter handles a single ) rather than from
+/// the enum, so adding/removing an adapter changes the UI
+/// options automatically.
+///
+public interface INotificationChannelCatalog
+{
+ ///
+ /// The distinct notification channels that have a registered delivery adapter,
+ /// in a stable order. Never empty in a correctly configured central node.
+ ///
+ IReadOnlyList SupportedChannels { get; }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/NotificationChannelCatalog.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/NotificationChannelCatalog.cs
new file mode 100644
index 00000000..b2f03310
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/NotificationChannelCatalog.cs
@@ -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;
+
+///
+/// Default that derives the supported channels
+/// from the registered set. The central Host
+/// registers the Email + SMS adapters (see AddNotificationOutbox ) into the same DI
+/// container that hosts the Central UI, so projecting each adapter's
+/// yields exactly the channels the central
+/// node can actually deliver — no hardcoded {Email, Sms} list to drift.
+///
+public sealed class NotificationChannelCatalog : INotificationChannelCatalog
+{
+ ///
+ /// 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 enum so the UI Type
+ /// selector renders in a stable order regardless of adapter registration order.
+ ///
+ /// The registered delivery adapters (one per channel).
+ public NotificationChannelCatalog(IEnumerable adapters)
+ {
+ ArgumentNullException.ThrowIfNull(adapters);
+
+ SupportedChannels = adapters
+ .Select(a => a.Type)
+ .Distinct()
+ .OrderBy(t => (int)t)
+ .ToArray();
+ }
+
+ ///
+ public IReadOnlyList SupportedChannels { get; }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj
index 814465de..4d57a831 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj
@@ -28,6 +28,11 @@
+
+
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListFormTests.cs
new file mode 100644
index 00000000..85a65c5f
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListFormTests.cs
@@ -0,0 +1,204 @@
+using System.Security.Claims;
+using Bunit;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+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;
+using ZB.MOM.WW.ScadaBridge.Security;
+using NotificationListForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationListForm;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
+
+///
+/// bUnit tests for the adapter-gated Type selector + per-type recipient input on the
+/// Notification List create/edit form (SMS Notifications, S7).
+///
+public class NotificationListFormTests : BunitContext
+{
+ private readonly INotificationRepository _repo = Substitute.For();
+
+ public NotificationListFormTests()
+ {
+ Services.AddSingleton(_repo);
+ // The Type selector derives its options from INotificationChannelCatalog
+ // (S7). Register a substitute exposing both channels so the selector renders
+ // Email + SMS without pulling the real adapter graph into the test.
+ var catalog = Substitute.For();
+ catalog.SupportedChannels.Returns(
+ new[] { NotificationType.Email, NotificationType.Sms });
+ Services.AddSingleton(catalog);
+ AddTestAuth();
+ }
+
+ private void AddTestAuth()
+ {
+ var claims = new[]
+ {
+ new Claim(JwtTokenService.UsernameClaimType, "tester"),
+ new Claim(JwtTokenService.RoleClaimType, "Designer"),
+ };
+ var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
+ Services.AddSingleton(new TestAuthStateProvider(user));
+ Services.AddAuthorizationCore();
+ }
+
+ [Fact]
+ public void TypeSelector_RendersRegisteredChannels()
+ {
+ var cut = Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ var optionTexts = cut.FindAll("select.form-select option")
+ .Select(o => o.TextContent.Trim())
+ .ToList();
+ Assert.Contains("Email", optionTexts);
+ Assert.Contains("SMS", optionTexts);
+ });
+ }
+
+ [Fact]
+ public void TypeSelector_IsEnabled_OnCreate()
+ {
+ var cut = Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ var select = cut.Find("select.form-select");
+ Assert.False(select.HasAttribute("disabled"));
+ });
+ }
+
+ [Fact]
+ public void SelectingEmail_ShowsEmailInput_NotPhone()
+ {
+ // Edit mode renders the recipient form (recipients only show once a list exists).
+ _repo.GetNotificationListByIdAsync(1)
+ .Returns(Task.FromResult(new NotificationList("Ops") { Id = 1, Type = NotificationType.Email }));
+ _repo.GetRecipientsByListIdAsync(1)
+ .Returns(Task.FromResult>(new List()));
+
+ var cut = Render(p => p.Add(c => c.Id, 1));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.NotNull(cut.Find("input[type=email]"));
+ Assert.Empty(cut.FindAll("input[type=tel]"));
+ // The recipients table column header reflects the list type.
+ Assert.Contains("Email", cut.FindAll("th").Select(t => t.TextContent.Trim()));
+ });
+ }
+
+ [Fact]
+ public void SelectingSms_ShowsPhoneInput_NotEmail()
+ {
+ _repo.GetNotificationListByIdAsync(2)
+ .Returns(Task.FromResult(new NotificationList("On-Call") { Id = 2, Type = NotificationType.Sms }));
+ _repo.GetRecipientsByListIdAsync(2)
+ .Returns(Task.FromResult>(new List()));
+
+ var cut = Render(p => p.Add(c => c.Id, 2));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.NotNull(cut.Find("input[type=tel]"));
+ Assert.Empty(cut.FindAll("input[type=email]"));
+ Assert.Contains("Phone", cut.FindAll("th").Select(t => t.TextContent.Trim()));
+ });
+ }
+
+ [Fact]
+ public void TypeSelector_IsDisabled_OnEdit()
+ {
+ _repo.GetNotificationListByIdAsync(3)
+ .Returns(Task.FromResult(new NotificationList("Ops") { Id = 3, Type = NotificationType.Email }));
+ _repo.GetRecipientsByListIdAsync(3)
+ .Returns(Task.FromResult>(new List()));
+
+ var cut = Render(p => p.Add(c => c.Id, 3));
+
+ cut.WaitForAssertion(() =>
+ {
+ var select = cut.Find("select.form-select");
+ Assert.True(select.HasAttribute("disabled"));
+ });
+ }
+
+ [Fact]
+ public void CreatingSmsList_PersistsTypeSms()
+ {
+ NotificationList? captured = null;
+ _repo.AddNotificationListAsync(Arg.Do(l => captured = l))
+ .Returns(Task.CompletedTask);
+
+ var cut = Render();
+
+ cut.Find("input[type=text]").Change("On-Call");
+ // Switch the Type selector to SMS, then save.
+ cut.Find("select.form-select").Change(NotificationType.Sms.ToString());
+ cut.FindAll("button").First(b => b.TextContent.Trim() == "Save").Click();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.NotNull(captured);
+ Assert.Equal(NotificationType.Sms, captured!.Type);
+ Assert.Equal("On-Call", captured.Name);
+ });
+ }
+
+ [Fact]
+ public void AddingSmsRecipient_CreatesForSmsRecipient_WithPhoneSet()
+ {
+ NotificationRecipient? captured = null;
+ _repo.GetNotificationListByIdAsync(4)
+ .Returns(Task.FromResult(new NotificationList("On-Call") { Id = 4, Type = NotificationType.Sms }));
+ _repo.GetRecipientsByListIdAsync(4)
+ .Returns(Task.FromResult>(new List()));
+ _repo.AddRecipientAsync(Arg.Do(r => captured = r))
+ .Returns(Task.CompletedTask);
+
+ var cut = Render(p => p.Add(c => c.Id, 4));
+
+ cut.WaitForState(() => cut.FindAll("input[type=tel]").Count > 0);
+
+ // The first text input is the list Name; the recipient Name input is the last.
+ cut.FindAll("input[type=text]").Last().Change("Jane");
+ cut.Find("input[type=tel]").Change("+15551234567");
+ cut.FindAll("button").First(b => b.TextContent.Trim() == "Add").Click();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.NotNull(captured);
+ Assert.Equal("+15551234567", captured!.PhoneNumber);
+ Assert.Null(captured.EmailAddress);
+ Assert.Equal(4, captured.NotificationListId);
+ });
+ }
+
+ [Fact]
+ public void AddingSmsRecipient_RejectsInvalidPhone()
+ {
+ _repo.GetNotificationListByIdAsync(5)
+ .Returns(Task.FromResult(new NotificationList("On-Call") { Id = 5, Type = NotificationType.Sms }));
+ _repo.GetRecipientsByListIdAsync(5)
+ .Returns(Task.FromResult>(new List()));
+
+ var cut = Render(p => p.Add(c => c.Id, 5));
+
+ cut.WaitForState(() => cut.FindAll("input[type=tel]").Count > 0);
+
+ // The first text input is the list Name; the recipient Name input is the last.
+ cut.FindAll("input[type=text]").Last().Change("Jane");
+ cut.Find("input[type=tel]").Change("not-a-number");
+ cut.FindAll("button").First(b => b.TextContent.Trim() == "Add").Click();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Contains("valid phone number", cut.Markup);
+ _repo.DidNotReceive().AddRecipientAsync(Arg.Any());
+ });
+ }
+}