22 KiB
SMS Notifications (T9 pivot + T10) Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.
Goal: Add NotificationType.Sms + a Twilio-REST SmsNotificationDeliveryAdapter behind the existing INotificationDeliveryAdapter seam, plus the T10 enum/UI/CLI Type-selector plumbing, so notification lists can deliver to ZB team members by SMS. Email is unchanged.
Architecture: Mirror the Email/SMTP stack. A list's existing NotificationList.Type decides the channel; Notify.To stays type-agnostic; central stamps the Notification's Type from the list at ingest; the outbox actor already routes Dictionary<NotificationType, adapter> and parks on a missing adapter. Twilio called directly over REST (no SDK). Secrets encrypted via the existing Data-Protection EncryptedStringConverter.
Tech Stack: C#/.NET 10, Akka.NET, EF Core (MS SQL), Blazor Server, System.CommandLine, IHttpClientFactory, ASP.NET Data Protection. No new NuGet packages (Microsoft.Extensions.Http is already centrally managed).
Design doc: docs/plans/2026-06-19-sms-notifications-design.md (committed 28cab6e8).
Execution rules: dedicated worktree feat/sms-notifications off main c72d7b79. Implementers do NOT create worktrees. Pathspec commits only (git commit -m "msg" -- <paths>, -m before --, never git add -A; git add <explicit path> for new files; retry on index.lock). ≤2-3 concurrent committers per wave + post-wave HEAD-presence check (git merge-base --is-ancestor <sha> HEAD). Targeted builds/tests per task; full-solution build + docker rebuild + Playwright + live smoke only at INT (S11). TreatWarningsAsErrors=true.
Wave / dependency map
- Wave 1: S1 (Commons foundation) — blocks all.
- Wave 2: S2 (Config-DB migration) ∥ S5 (Management commands+handlers). Both ⟵ S1.
- Wave 3: S3 (adapter) ⟵ S1,S2 ∥ S4 (ingest stamping) ⟵ S1 ∥ S6 (CLI) ⟵ S5.
- Wave 4: S7 (list form) ∥ S8 (list page Type column) ∥ S9 (SMS config page) ∥ S10 (Transport). All ⟵ S1/S5 (S9,S10 also ⟵ S2). Cap at 3 concurrent; queue the 4th.
- Wave 5: S11 (INT) ⟵ all.
Task S1: Commons foundation — NotificationType.Sms, recipient phone, SmsConfiguration entity, repo interface
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (foundational)
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/NotificationType.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Notifications/NotificationRecipient.cs - Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Notifications/SmsConfiguration.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationRepository.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/(add a recipient-factory + SmsConfiguration shape test if a suitable test class exists; else a small new test file)
Steps:
- Add
SmstoNotificationType(afterEmail). Update the enum's XML doc to "Email and SMS supported." NOTE:Commons.Testsmay have an enum-count assertion — if so, update it (this is the known enum-count-drift pattern). NotificationRecipient: addpublic string? PhoneNumber { get; set; }; relaxEmailAddresstostring?. Keep the existingNotificationRecipient(string name, string emailAddress)ctor (email path). Add static factoriesForEmail(string name, string email)andForSms(string name, string phoneNumber)(set the appropriate field, leave the other null; validate name non-empty). Keep a parameterless-friendly path for EF.- Create
SmsConfigurationPOCO (persistence-ignorant):int Id,string AccountSid,string? AuthToken,string FromNumber,string? MessagingServiceSid,string? ApiBaseUrl,int ConnectionTimeoutSeconds,int MaxRetries,TimeSpan RetryDelay. MirrorSmtpConfigurationconstructor/validation style. INotificationRepository: addTask<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken=default),Task<IReadOnlyList<SmsConfiguration>> GetAllSmsConfigurationsAsync(CancellationToken=default),Task AddSmsConfigurationAsync(SmsConfiguration, CancellationToken=default),Task UpdateSmsConfigurationAsync(SmsConfiguration, CancellationToken=default)(match the SMTP method shapes already present).- Build
dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/...; run any touched Commons tests. - Commit (pathspec;
git addthe new SmsConfiguration.cs).
Acceptance: Commons builds 0/0; NotificationType.Sms exists; recipient carries nullable phone + factories; SmsConfiguration + repo methods defined.
Task S2: Configuration Database — EF mappings, encryption, idempotent migration
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: S5
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/NotificationConfiguration.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/NotificationRepository.cs(or whereverINotificationRepositoryis implemented — locate it) - Create: new EF migration under
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ - Test:
tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/(mapping/round-trip + encryption test)
Steps:
NotificationRecipientConfiguration:EmailAddress→.IsRequired(false); addPhoneNumber.HasMaxLength(32)(nullable).- Add
SmsConfigurationConfiguration : IEntityTypeConfiguration<SmsConfiguration>(key,AccountSidrequired maxlen,FromNumberrequired,AuthToken/MessagingServiceSid/ApiBaseUrlnullable, sensible maxlens). AddDbSet<SmsConfiguration>+ apply config. ScadaBridgeDbContext.ApplySecretColumnEncryption: addmodelBuilder.Entity<SmsConfiguration>().Property(s => s.AuthToken).HasConversion(converter);.- Implement the four
INotificationRepositorySMS-config methods (mirror SMTP impls exactly). - Generate the migration (
dotnet ef migrations add AddSmsNotifications ...). Then make it idempotent per repo convention (guard column add / table create / column-alter the way prior migrations in this repo do — match an existing idempotent migration as reference). Migration must: addNotificationRecipients.PhoneNumber(nullable), alterNotificationRecipients.EmailAddressto nullable, createSmsConfigurationstable. - Verify NO
PendingModelChangesWarning:dotnet build+ a model-drift check (the repo has a pattern/test for this — run it). - Build Config-DB + run touched Config-DB tests. Commit (pathspec).
Acceptance: Config-DB builds; migration idempotent + no model drift; encryption applied to AuthToken; recipient phone + nullable email mapped; round-trip test green.
Task S3: SmsNotificationDeliveryAdapter (Twilio REST) + classifier + options + DI + adapter tests
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: S4, S6
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsNotificationDeliveryAdapter.cs - Create:
src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsErrorClassifier.cs - Create:
src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/SmsOptions.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj - Modify (test):
tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs - Create (test):
tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/SmsNotificationDeliveryAdapterTests.cs
Steps:
SmsErrorClassifier: staticIsTransient(HttpStatusCode)(5xx/408/429) +IsTransient(Exception, CancellationToken)(HttpRequestException/Timeout/TaskCanceled when not caller-cancelled). MirrorSmtpErrorClassifier's enum+static-method shape.SmsOptions(bindScadaBridge:Sms):int MaxMessageLength = 1600,int ConnectionTimeoutSeconds = 30(config-row value overrides). Validate.SmsNotificationDeliveryAdapter : INotificationDeliveryAdapter,Type => NotificationType.Sms. Ctor:INotificationRepository,IHttpClientFactory,ILogger,IOptions<SmsOptions>.DeliverAsync:- Load list by
notification.ListName→Permanentif missing. - Load recipients; filter to non-empty
PhoneNumber→Permanentif none. - Load
GetSmsConfigurationAsync()→Permanentif null/incomplete. - Compose body =
Subject+ "\n" +Body, truncate toMaxMessageLength. - For each phone:
POST {ApiBaseUrl}/2010-04-01/Accounts/{AccountSid}/Messages.json, Basic auth headerAccountSid:AuthToken, formTo/From(or MessagingServiceSid)/Body. Classify per response. - Roll up per D6 (all-accepted→Success; any-transient→Transient; mix accepted+permanent→Success + LastError note; all-permanent→Permanent).
ResolvedTargets= accepted numbers. - Scrub AuthToken from any logged error via
CredentialRedactor.Scrub.
- Load list by
ServiceCollectionExtensions.AddNotificationOutbox: registerSmsNotificationDeliveryAdapteras scopedINotificationDeliveryAdapter(alongside Email);services.AddHttpClient("Twilio"); bindSmsOptions..csproj: add<PackageReference Include="Microsoft.Extensions.Http" />(version comes from Directory.Packages.props — do NOT add a version attribute).- Update
ServiceRegistrationTests: theAssert.Single(adapters)test must now assert both Email and SMS adapters are registered (e.g.Assert.Equal(2, adapters.Count)+ both types present); keep/add aSmsNotificationDeliveryAdapter.Type == Smsassertion. - Adapter unit tests with a fake
HttpMessageHandler: 201 success (single + multi recipient), 500/429/timeout → Transient, 400/401 → Permanent, mix accepted+400 → Success with LastError note, no-config/no-recipients/list-missing → Permanent, AuthToken never appears in returned error string. - Build NotificationOutbox + run NotificationOutbox.Tests. Commit (pathspec;
git addnew files).
Acceptance: adapter builds; both adapters registered; all adapter unit tests + the updated ServiceRegistration test green; no AuthToken leakage.
Task S4: Ingest type-stamping in NotificationOutboxActor
Classification: standard Estimated implement time: ~3 min Parallelizable with: S3, S6
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs(the ingest path that currently hard-codesNotificationType.Email, ~line 1147) - Test:
tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/(ingest type-stamping test)
Steps:
- At ingest, resolve the target list (
GetListByNameAsync(msg.ListName)); stampType = list?.Type ?? NotificationType.Emailonto the newNotification. Keep the at-least-once insert-if-not-exists semantics intact (do NOT change idempotency). Add a brief comment replacing the old "all notifications are email" comment. - Test: ingest for an Email list stamps Email; ingest for an Sms list stamps Sms; ingest for a missing list defaults Email (and later parks at delivery — assert default only).
- Build + run NotificationOutbox.Tests (filtered). Commit (pathspec).
Acceptance: notifications inherit the list's Type at ingest; idempotency unchanged; tests green.
Task S5: Management — list-command Type + SMS-config commands/handlers
Classification: standard Estimated implement time: ~5 min Parallelizable with: S2
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/NotificationCommands.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/(handler tests)
Steps:
NotificationCommands.cs: addNotificationType Type = NotificationType.EmailtoCreateNotificationListCommandandUpdateNotificationListCommand(additive trailing param). Addrecord ListSmsConfigsCommand;andrecord UpdateSmsConfigCommand(int SmsConfigId, string AccountSid, string FromNumber, string? MessagingServiceSid = null, string? ApiBaseUrl = null, string? AuthToken = null);(mirrorUpdateSmtpConfigCommand, preserve-if-null for AuthToken).ManagementActor: in Create/Update list handlers, persistcmd.Typeon theNotificationList. AddHandleListSmsConfigs+HandleUpdateSmsConfig(preserve-if-null AuthToken/ApiBaseUrl), role-gate (Designer/Admin per SMTP), and audit via a credential-freeSmsConfigPublicShape(mirrorSmtpConfigPublicShape— never log/return AuthToken). Wire the command routing entries.- Handler tests: create list with Type=Sms persists Sms; update SMS config preserves AuthToken when null, never surfaces AuthToken in audit/response.
- Build Commons + ManagementService + run ManagementService.Tests (filtered). Commit (pathspec).
Acceptance: list commands carry Type; SMS-config commands/handlers exist, role-gated, secret-safe; tests green.
Task S6: CLI — list --type/--phones + notification sms group
Classification: standard Estimated implement time: ~5 min Parallelizable with: S3, S4
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.CLI/README.md - Test:
tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/
Steps:
- List
create/update: add--type email|sms(default email) and--phones(comma-separated E.164). Validate: email type requires--emails; sms type requires--phones; reject mismatch. BuildCreate/UpdateNotificationListCommandwithType+ the right recipient set. (Recipients for SMS map to phone numbers — coordinate with how recipients are submitted; if recipients are added via a separate command, set Type on the list and feed phones through the recipient path.) - Add
notification sms list(→ListSmsConfigsCommand) andnotification sms update --id --account-sid --from-number [--messaging-service-sid] [--api-base-url] [--auth-token](→UpdateSmsConfigCommand), mirroring thenotification smtpgroup. Render JSON/table; never print AuthToken. - Update CLI README (notification section:
--type/--phones,smsgroup). - Build CLI + run CLI.Tests (filtered). Commit (pathspec).
Acceptance: CLI creates SMS lists + manages SMS config; validation enforced; secret never printed; tests + README updated.
Task S7: Central UI — NotificationListForm Type selector + per-type recipient input
Classification: standard Estimated implement time: ~5 min Parallelizable with: S8, S9, S10
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationListForm.razor - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/(NotificationListForm tests)
Steps:
- Add an adapter-gated Type selector: source the options from the registered
INotificationDeliveryAdapterTypes (inject the adapter set or a small accessor; if injecting the adapters into the UI is awkward, expose a server-side accessor that returns the registeredNotificationTypeset). Today renders Email + SMS. Selector enabled on create; read-only on edit (Id.HasValue) to prevent recipient-kind mismatch. - Recipient editor: when Type=Email show the Email input + validate email; when Type=Sms show a Phone input + validate E.164. Build the recipient via
NotificationRecipient.ForEmail/ForSms. Update the recipients table to show Email or Phone per type. - On save, pass
Typeto the create command / new list. - bUnit tests: selector renders Email+SMS; choosing SMS shows phone input + validation; create persists Type; edit shows Type read-only. Razor hygiene: any
@* *@comment must sit OUTSIDE element start tags (the recurring circuit-crash landmine). - Build CentralUI + run the NotificationListForm tests (filtered). Commit (pathspec).
Acceptance: Type selector adapter-gated + create-only; per-type recipient input + validation; tests green; no Razor start-tag comments.
Task S8: Central UI — NotificationLists Type column
Classification: small Estimated implement time: ~3 min Parallelizable with: S7, S9, S10
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationLists.razor - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs
Steps:
- Add a Type column to the lists table (render
NotificationType). Keep markup tokenized/consistent with the M10 table conventions. - Update/extend
NotificationListsPageTeststo assert the Type column renders (e.g. an SMS list shows "Sms"). - Build CentralUI + run NotificationListsPageTests. Commit (pathspec).
Acceptance: Type column renders; test green.
Task S9: Central UI — SMS configuration page
Classification: standard Estimated implement time: ~5 min Parallelizable with: S7, S8, S10
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmsConfiguration.razor - Modify: the nav/menu component that lists
/notifications/smtp(add/notifications/sms) - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/(SmsConfiguration page test)
Steps:
- New page
/notifications/sms(Admin-only, mirrorSmtpConfiguration.razor): list SMS configs, create/edit form (AccountSid, FromNumber, MessagingServiceSid, ApiBaseUrl, AuthToken — AuthToken write-only/masked, preserve-if-blank on edit), save via repository/management. Never render the stored AuthToken back. - Add the nav entry next to the SMTP settings entry.
- bUnit: page renders config rows; AuthToken input is masked/not pre-filled; save calls the repository. Razor start-tag comment hygiene.
- Build CentralUI + run the page test. Commit (pathspec;
git addnew file).
Acceptance: SMS config page works + Admin-gated + secret-safe; nav entry added; test green.
Task S10: Transport — recipient PhoneNumber DTO + SmsConfigDto round-trip
Classification: standard Estimated implement time: ~5 min Parallelizable with: S7, S8, S9
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.Transport.*Tests/
Steps:
NotificationRecipientDto: addstring? PhoneNumber(trailing, nullable → backward-compatible).SmsConfigDtomirroringSmtpConfigDto(secret inSecretsBlock). AddIReadOnlyList<SmsConfigDto> SmsConfigstoBundleContentDtoandIReadOnlyList<SmsConfiguration> SmsConfigurationstoEntityAggregate.EntitySerializer: map recipient PhoneNumber both directions; map SMS configs export/import (mirror SMTP, including SecretsBlock handling). KeepschemaVersionevolution additive per repo convention.- Tests: recipient phone round-trips; SMS config round-trips with secret in SecretsBlock; old bundle without PhoneNumber deserializes to null.
- Build Transport + run Transport tests (filtered). Commit (pathspec).
Acceptance: SMS recipient phone + SMS config travel in bundles; backward-compatible; tests green.
Task S11: Integration — build, migration drift, docker, Playwright, live smoke, docs, review
Classification: high-risk Estimated implement time: ~10 min (+ docker rebuild wall-time) Parallelizable with: none
Files:
- Modify:
docs/requirements/Component-NotificationService.md,docs/requirements/Component-NotificationOutbox.md - Modify:
CLAUDE.md(NotificationType = Email+Sms; T9/T10 delivered as SMS — flip the M6 deferral note; add the SMS-adapter decision bullet) - Modify:
README.md(component table / notes if needed) - Create (test):
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationListTests.cs(or extend existing notification Playwright)
Steps:
- Full-solution build:
dotnet build ZB.MOM.WW.ScadaBridge.slnx— 0 warnings/errors. - EF model-drift check (no
PendingModelChangesWarning). - Full unfiltered managed test run
dotnet test ZB.MOM.WW.ScadaBridge.slnx(run 2–3× for load-flake surfacing per the integration memory). Triage any red: prove pre-existing viagit diff --stat c72d7b79..HEAD -- <target files>before attributing. - Docker rebuild:
bash docker/deploy.sh;/health/ready200 on the active node. - Live smoke via CLI against the cluster: set an SMS config (fake AccountSid +
--api-base-urlpointed at a mock, OR document Twilio sandbox), create an--type smslist with a phone recipient, submit a notification, verify it routes to the SMS adapter and parks-with-reason when the fake returns an error (proves routing + classification end-to-end). Also verify an Email list still delivers (no regression). - Playwright: SMS-list create flow (Type selector → SMS → phone recipient → save) against the freshly-rebuilt cluster; broad notification-page smoke. Razor circuit-crash check: load
/notifications/lists/create+/notifications/smsin a real browser (the bUnit-invisible render-crash gate). - Docs reconciliation: Component docs + CLAUDE.md + README + CLI README synced; confirm the M6 deferral note is flipped to "delivered (SMS pivot)."
- Whole-branch integration review over
git diff c72d7b79..HEAD(Bash-capable reviewer): end-to-end trace of the newNotificationType.Smspath (submit → ingest stamp → adapter dispatch → Twilio POST → park/retry), shared-component injection regressions (any new ctor/[Inject]dep → grep every fixture across ALL test projects), and secret-leak audit (AuthToken never logged/returned/rendered). - Commit docs (pathspec). Then hand to finishing-a-development-branch.
Acceptance: full build + full managed tests green; docker healthy; live smoke proves SMS routing + Email no-regression; Playwright + render-crash gate green; docs synced; whole-branch review INTEGRATION-CLEAN.