From f0c69aad83c60ec97ec38f1e7bdef2e8038dc10c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 19 Jun 2026 10:38:29 -0400 Subject: [PATCH] feat(sms): NotificationListForm adapter-gated Type selector + per-type recipients (S7) --- .../Notifications/NotificationListForm.razor | 114 ++++++++-- .../ServiceCollectionExtensions.cs | 8 + .../Services/INotificationChannelCatalog.cs | 21 ++ .../Services/NotificationChannelCatalog.cs | 36 ++++ .../ZB.MOM.WW.ScadaBridge.CentralUI.csproj | 5 + .../Pages/NotificationListFormTests.cs | 204 ++++++++++++++++++ 6 files changed, 373 insertions(+), 15 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/INotificationChannelCatalog.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/NotificationChannelCatalog.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListFormTests.cs 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). *@ +
+ + +
+ @if (_formError != null) {
@_formError
@@ -47,10 +63,23 @@ -
- - -
+ @* Per-type contact field: email lists collect an email address; SMS + lists collect an E.164 phone number. *@ + @if (_type == NotificationType.Sms) + { +
+ + +
+ } + else + { +
+ + +
+ } @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) @@ -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()); + }); + } +}