From 0904401f1e48c4fd77794b0eda60f1493f1a954e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 19 May 2026 05:08:52 -0400 Subject: [PATCH] docs(plans): implementation plan for Notifications nav group --- .../2026-05-19-notifications-nav-group.md | 1446 +++++++++++++++++ ...5-19-notifications-nav-group.md.tasks.json | 19 + 2 files changed, 1465 insertions(+) create mode 100644 docs/plans/2026-05-19-notifications-nav-group.md create mode 100644 docs/plans/2026-05-19-notifications-nav-group.md.tasks.json diff --git a/docs/plans/2026-05-19-notifications-nav-group.md b/docs/plans/2026-05-19-notifications-nav-group.md new file mode 100644 index 0000000..43dcdce --- /dev/null +++ b/docs/plans/2026-05-19-notifications-nav-group.md @@ -0,0 +1,1446 @@ +# Notifications Nav Group Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Consolidate all notification-related Central UI pages under a new **Notifications** left-menu section, split the combined Outbox page into a report page and a KPIs page, give Notification Lists its own page, and add a per-source-site KPI breakdown. + +**Architecture:** Four pages move to a consistent `/notifications/*` route prefix and a new `Notifications` nav section. The existing `Monitoring/NotificationOutbox.razor` (KPI tiles + filterable table on one page) is split into `NotificationReport.razor` (table) and `NotificationKpis.razor` (tiles + per-site table). The KPI page's per-site breakdown is backed by a bounded, additive backend chain: a new repository method, message contract pair, actor handler, and `CommunicationService` method — each mirroring the existing global-KPI equivalent. + +**Tech Stack:** .NET 10, Blazor Server + Bootstrap, Akka.NET (cluster singleton actor, TestKit), EF Core (MS SQL; SQLite in-memory for tests), xUnit + NSubstitute + bUnit. + +**Design doc:** `docs/plans/2026-05-19-notifications-nav-group-design.md` + +--- + +## Conventions for every task + +- **TDD:** write the failing test first, watch it fail, implement, watch it pass. +- **Commit with explicit paths only.** NEVER `git add -A` or `git add .`. There are pre-existing uncommitted files under `infra/` that MUST NOT be committed — always `git add `. +- Build the solution with `dotnet build ScadaLink.slnx`. `TreatWarningsAsErrors` is on — zero warnings. +- Run a single test project with `dotnet test tests//.csproj`. +- Entity/domain POCOs live in `ScadaLink.Commons`; EF fluent configs in `ScadaLink.ConfigurationDatabase`. +- Message records follow additive-only evolution — only add new record types, never reorder/remove members of existing ones. + +--- + +## Task 1: Per-site KPI domain type + repository interface method + +**Files:** +- Create: `src/ScadaLink.Commons/Types/Notifications/SiteNotificationKpiSnapshot.cs` +- Modify: `src/ScadaLink.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs` +- Test: `tests/ScadaLink.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs` + +**Step 1: Write the failing test** + +```csharp +using ScadaLink.Commons.Types.Notifications; + +namespace ScadaLink.Commons.Tests.Types; + +public class SiteNotificationKpiSnapshotTests +{ + [Fact] + public void Constructor_AssignsAllMembers() + { + var snapshot = new SiteNotificationKpiSnapshot( + SourceSiteId: "plant-a", + QueueDepth: 5, + StuckCount: 2, + ParkedCount: 1, + DeliveredLastInterval: 40, + OldestPendingAge: TimeSpan.FromMinutes(12)); + + Assert.Equal("plant-a", snapshot.SourceSiteId); + Assert.Equal(5, snapshot.QueueDepth); + Assert.Equal(2, snapshot.StuckCount); + Assert.Equal(1, snapshot.ParkedCount); + Assert.Equal(40, snapshot.DeliveredLastInterval); + Assert.Equal(TimeSpan.FromMinutes(12), snapshot.OldestPendingAge); + } + + [Fact] + public void OldestPendingAge_IsNullableForSitesWithNoBacklog() + { + var snapshot = new SiteNotificationKpiSnapshot("plant-b", 0, 0, 0, 0, null); + Assert.Null(snapshot.OldestPendingAge); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter SiteNotificationKpiSnapshotTests` +Expected: FAIL — `SiteNotificationKpiSnapshot` does not exist. + +**Step 3: Write minimal implementation** + +`SiteNotificationKpiSnapshot.cs`: + +```csharp +namespace ScadaLink.Commons.Types.Notifications; + +/// +/// Point-in-time notification-outbox metrics scoped to a single source site. +/// The per-site counterpart of ; surfaced +/// in the per-site breakdown table on the Notification KPIs page. +/// +/// The site identifier these metrics are scoped to. +/// Count of this site's non-terminal rows (Pending + Retrying). +/// +/// Count of this site's non-terminal rows whose CreatedAt is older than the stuck cutoff. +/// +/// Count of this site's rows in the Parked status. +/// +/// Count of this site's Delivered rows whose DeliveredAt is at or after the +/// "delivered since" timestamp. +/// +/// +/// Age of this site's oldest non-terminal row, or null when it has none. +/// +public record SiteNotificationKpiSnapshot( + string SourceSiteId, + int QueueDepth, + int StuckCount, + int ParkedCount, + int DeliveredLastInterval, + TimeSpan? OldestPendingAge); +``` + +Add to `INotificationOutboxRepository.cs`, immediately after the `ComputeKpisAsync` method: + +```csharp + /// + /// Computes a point-in-time per source site. + /// Sites with no notification rows at all are omitted. The stuck and delivered cutoffs + /// are supplied by the caller; the current time used for OldestPendingAge is + /// captured inside the method. + /// + Task> ComputePerSiteKpisAsync( + DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default); +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter SiteNotificationKpiSnapshotTests` +Expected: PASS. (`ScadaLink.ConfigurationDatabase` will not yet compile — the interface method is unimplemented. That is fixed in Task 2; do not build the whole solution at this step.) + +**Step 5: Commit** + +```bash +git add src/ScadaLink.Commons/Types/Notifications/SiteNotificationKpiSnapshot.cs \ + src/ScadaLink.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs \ + tests/ScadaLink.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs +git commit -m "feat(notification-outbox): per-site KPI snapshot type + repository contract" +``` + +--- + +## Task 2: ComputePerSiteKpisAsync repository implementation + +**Files:** +- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs` +- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs` + +**Context:** `NotificationOutboxRepository` is an EF Core repository over `ScadaLinkDbContext.Notifications`. The existing global `ComputeKpisAsync` (in the same file) shows the metric definitions and the `DateTimeOffset`-min workaround. The `Notification` entity has a `SourceSiteId` string and a `Status` (`NotificationStatus` enum: `Pending`, `Retrying`, `Delivered`, `Parked`, `Discarded`). Look at an existing repository test in `tests/ScadaLink.ConfigurationDatabase.Tests/` to copy the SQLite-in-memory `ScadaLinkDbContext` setup pattern. + +**Step 1: Write the failing test** + +Mirror the existing repository-test setup (in-memory SQLite `ScadaLinkDbContext`). Seed `Notification` rows across two sites and assert the per-site aggregation: + +```csharp +[Fact] +public async Task ComputePerSiteKpisAsync_AggregatesMetricsPerSite() +{ + await using var ctx = NewContext(); // copy the in-memory-SQLite helper used by sibling tests + var now = DateTimeOffset.UtcNow; + + // plant-a: 1 pending (stuck, created 20m ago), 1 parked + ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending, createdAt: now.AddMinutes(-20))); + ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Parked, createdAt: now.AddMinutes(-5))); + // plant-b: 1 delivered in-window, 1 pending (fresh) + ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Delivered, createdAt: now.AddHours(-2), deliveredAt: now.AddMinutes(-2))); + ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Pending, createdAt: now.AddMinutes(-1))); + await ctx.SaveChangesAsync(); + + var repo = new NotificationOutboxRepository(ctx); + var result = await repo.ComputePerSiteKpisAsync( + stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30)); + + var a = result.Single(s => s.SourceSiteId == "plant-a"); + Assert.Equal(1, a.QueueDepth); + Assert.Equal(1, a.StuckCount); + Assert.Equal(1, a.ParkedCount); + Assert.Equal(0, a.DeliveredLastInterval); + Assert.NotNull(a.OldestPendingAge); + + var b = result.Single(s => s.SourceSiteId == "plant-b"); + Assert.Equal(1, b.QueueDepth); + Assert.Equal(0, b.StuckCount); + Assert.Equal(1, b.DeliveredLastInterval); +} + +[Fact] +public async Task ComputePerSiteKpisAsync_ReturnsEmpty_WhenNoNotifications() +{ + await using var ctx = NewContext(); + var repo = new NotificationOutboxRepository(ctx); + var result = await repo.ComputePerSiteKpisAsync( + DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(-30)); + Assert.Empty(result); +} +``` + +Define the `NewContext()` and `NewNotification(...)` helpers by copying the conventions from the nearest existing `NotificationOutboxRepository` test (check `RepositoryCoverageTests.cs` for how `Notification` rows are constructed). + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj --filter NotificationOutboxRepositoryPerSiteKpiTests` +Expected: FAIL — `ComputePerSiteKpisAsync` not implemented. + +**Step 3: Write minimal implementation** + +Add to `NotificationOutboxRepository.cs`, after `ComputeKpisAsync`: + +```csharp + public async Task> ComputePerSiteKpisAsync( + DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + + var queueDepth = await CountBySiteAsync( + n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying, + cancellationToken); + + var stuck = await CountBySiteAsync( + n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) + && n.CreatedAt < stuckCutoff, + cancellationToken); + + var parked = await CountBySiteAsync( + n => n.Status == NotificationStatus.Parked, cancellationToken); + + var delivered = await CountBySiteAsync( + n => n.Status == NotificationStatus.Delivered + && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince, + cancellationToken); + + // Oldest non-terminal CreatedAt per site. A SQL Min over the DateTimeOffset + // converter is awkward (see ComputeKpisAsync), so project the non-terminal + // (site, created) pairs — the live queue, which stays bounded — and reduce + // in memory. + var oldest = (await _context.Notifications + .Where(n => n.Status == NotificationStatus.Pending + || n.Status == NotificationStatus.Retrying) + .Select(n => new { n.SourceSiteId, n.CreatedAt }) + .ToListAsync(cancellationToken)) + .GroupBy(x => x.SourceSiteId) + .ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt)); + + var siteIds = queueDepth.Keys + .Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys) + .Distinct() + .OrderBy(s => s, StringComparer.Ordinal); + + return siteIds.Select(site => new SiteNotificationKpiSnapshot( + SourceSiteId: site, + QueueDepth: queueDepth.GetValueOrDefault(site), + StuckCount: stuck.GetValueOrDefault(site), + ParkedCount: parked.GetValueOrDefault(site), + DeliveredLastInterval: delivered.GetValueOrDefault(site), + OldestPendingAge: oldest.TryGetValue(site, out var createdAt) + ? now - createdAt + : null)).ToList(); + } + + /// Counts notification rows matching , grouped by source site. + private async Task> CountBySiteAsync( + System.Linq.Expressions.Expression> predicate, + CancellationToken cancellationToken) + { + return await _context.Notifications + .Where(predicate) + .GroupBy(n => n.SourceSiteId) + .Select(g => new { Site = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken); + } +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj --filter NotificationOutboxRepositoryPerSiteKpiTests` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs \ + tests/ScadaLink.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs +git commit -m "feat(notification-outbox): per-site KPI aggregation in the repository" +``` + +--- + +## Task 3: Per-site KPI message contracts + +**Files:** +- Modify: `src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs` +- Test: `tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs` (add cases) + +**Context:** `NotificationOutboxQueries.cs` already holds `NotificationKpiRequest` / `NotificationKpiResponse`. The per-site response carries the `SiteNotificationKpiSnapshot` records directly (no flattening — the type is a clean serializable record in Commons). Additive-only: append new records, do not touch existing ones. + +**Step 1: Write the failing test** + +Add to `NotificationMessagesTests.cs`: + +```csharp +[Fact] +public void PerSiteNotificationKpiRequest_CarriesCorrelationId() +{ + var request = new PerSiteNotificationKpiRequest("corr-1"); + Assert.Equal("corr-1", request.CorrelationId); +} + +[Fact] +public void PerSiteNotificationKpiResponse_CarriesPerSiteSnapshots() +{ + var sites = new[] + { + new SiteNotificationKpiSnapshot("plant-a", 3, 1, 0, 10, TimeSpan.FromMinutes(4)), + }; + var response = new PerSiteNotificationKpiResponse("corr-1", Success: true, ErrorMessage: null, sites); + + Assert.True(response.Success); + Assert.Null(response.ErrorMessage); + Assert.Single(response.Sites); + Assert.Equal("plant-a", response.Sites[0].SourceSiteId); +} +``` + +Add `using ScadaLink.Commons.Types.Notifications;` to the test file if not present. + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter NotificationMessagesTests` +Expected: FAIL — the two records do not exist. + +**Step 3: Write minimal implementation** + +Append to `NotificationOutboxQueries.cs` (add `using ScadaLink.Commons.Types.Notifications;` at the top if absent): + +```csharp +/// +/// Outbox UI -> Central: request for the per-source-site notification outbox KPI breakdown. +/// +public record PerSiteNotificationKpiRequest( + string CorrelationId); + +/// +/// Central -> Outbox UI: per-site KPI breakdown for the Notification KPIs page. +/// On a repository fault is false, +/// carries the cause, and is empty. +/// +public record PerSiteNotificationKpiResponse( + string CorrelationId, + bool Success, + string? ErrorMessage, + IReadOnlyList Sites); +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter NotificationMessagesTests` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs \ + tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs +git commit -m "feat(notification-outbox): per-site KPI request/response message contracts" +``` + +--- + +## Task 4: NotificationOutboxActor per-site KPI handler + +**Files:** +- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` +- Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs` (add cases) + +**Context:** The actor registers handlers in its constructor and resolves `INotificationOutboxRepository` per-request from a fresh DI scope (`_serviceProvider.CreateScope()`) to avoid a captive dependency. `HandleKpiRequest` / `ComputeKpisAsync` are the exact template to mirror. `StuckCutoff(now)` and `_options.DeliveredKpiWindow` already exist. `NotificationOutboxActorQueryTests.cs` shows how the actor is spun up with a substituted repository. + +**Step 1: Write the failing test** + +Add to `NotificationOutboxActorQueryTests.cs`, following the existing KPI-request test in that file: + +```csharp +[Fact] +public void PerSiteKpiRequest_RepliesWithPerSiteSnapshots() +{ + var repo = Substitute.For(); + repo.ComputePerSiteKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List + { + new("plant-a", 4, 1, 0, 9, TimeSpan.FromMinutes(7)), + }); + + var actor = CreateActor(repo); // reuse the helper this test class already uses + actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor); + + var response = ExpectMsg(); + Assert.True(response.Success); + Assert.Equal("corr-ps", response.CorrelationId); + Assert.Single(response.Sites); + Assert.Equal("plant-a", response.Sites[0].SourceSiteId); +} + +[Fact] +public void PerSiteKpiRequest_RepositoryFault_RepliesUnsuccessful() +{ + var repo = Substitute.For(); + repo.ComputePerSiteKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns>(_ => throw new InvalidOperationException("db down")); + + var actor = CreateActor(repo); + actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor); + + var response = ExpectMsg(); + Assert.False(response.Success); + Assert.Contains("db down", response.ErrorMessage); + Assert.Empty(response.Sites); +} +``` + +Match the seam the existing KPI test uses (the `CreateActor` helper name may differ — copy whatever the sibling KPI test does). + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj --filter NotificationOutboxActorQueryTests` +Expected: FAIL — `PerSiteNotificationKpiRequest` is unhandled (TestKit reports an unexpected message / no reply). + +**Step 3: Write minimal implementation** + +In the constructor of `NotificationOutboxActor`, after the `Receive(HandleKpiRequest);` line: + +```csharp + Receive(HandlePerSiteKpiRequest); +``` + +After the `ComputeKpisAsync` method, add: + +```csharp + /// + /// Handles a per-site KPI request, computing the per-source-site outbox metrics with the + /// same stuck cutoff and delivered window as . + /// + private void HandlePerSiteKpiRequest(PerSiteNotificationKpiRequest request) + { + var sender = Sender; + var now = DateTimeOffset.UtcNow; + var stuckCutoff = StuckCutoff(now); + var deliveredSince = now - _options.DeliveredKpiWindow; + + ComputePerSiteKpisAsync(request.CorrelationId, stuckCutoff, deliveredSince).PipeTo( + sender, + success: response => response, + failure: ex => new PerSiteNotificationKpiResponse( + request.CorrelationId, + Success: false, + ErrorMessage: ex.GetBaseException().Message, + Sites: Array.Empty())); + } + + private async Task ComputePerSiteKpisAsync( + string correlationId, DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince) + { + using var scope = _serviceProvider.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var sites = await repository.ComputePerSiteKpisAsync(stuckCutoff, deliveredSince); + + return new PerSiteNotificationKpiResponse(correlationId, Success: true, ErrorMessage: null, sites); + } +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj --filter NotificationOutboxActorQueryTests` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs \ + tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs +git commit -m "feat(notification-outbox): actor handler for per-site KPI requests" +``` + +--- + +## Task 5: CommunicationService.GetPerSiteNotificationKpisAsync + +**Files:** +- Modify: `src/ScadaLink.Communication/CommunicationService.cs` +- Test: `tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs` (add a case) + +**Context:** `CommunicationService` has a "Notification Outbox" region. `GetNotificationKpisAsync` is the exact template — it `Ask`s the notification-outbox actor proxy (`GetNotificationOutbox()`) with `_options.QueryTimeout`. The test class wires a real lightweight `ActorSystem` with a scripted actor (the same `SetNotificationOutbox` seam the outbox page tests use). + +**Step 1: Write the failing test** + +Add to `CommunicationServiceTests.cs`, mirroring the existing `GetNotificationKpisAsync` test: + +```csharp +[Fact] +public async Task GetPerSiteNotificationKpisAsync_RoundTripsThroughTheOutboxActor() +{ + var expected = new PerSiteNotificationKpiResponse("corr-ps", true, null, + new[] { new SiteNotificationKpiSnapshot("plant-a", 2, 0, 0, 5, null) }); + + // Scripted actor replies with `expected` to any PerSiteNotificationKpiRequest — + // copy the scripted-actor pattern used by the sibling KPI test. + var service = CreateServiceWithScriptedOutbox(_ => expected); + + var actual = await service.GetPerSiteNotificationKpisAsync(new PerSiteNotificationKpiRequest("corr-ps")); + + Assert.True(actual.Success); + Assert.Single(actual.Sites); + Assert.Equal("plant-a", actual.Sites[0].SourceSiteId); +} +``` + +If the test class uses an inline scripted `ReceiveActor` rather than a `CreateServiceWith...` helper, follow that existing shape instead — the point is a scripted reply to `PerSiteNotificationKpiRequest`. + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj --filter GetPerSiteNotificationKpisAsync` +Expected: FAIL — `GetPerSiteNotificationKpisAsync` does not exist. + +**Step 3: Write minimal implementation** + +In `CommunicationService.cs`, in the Notification Outbox region, after `GetNotificationKpisAsync`: + +```csharp + public async Task GetPerSiteNotificationKpisAsync( + PerSiteNotificationKpiRequest request, CancellationToken cancellationToken = default) + { + return await GetNotificationOutbox().Ask( + request, _options.QueryTimeout, cancellationToken); + } +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj --filter GetPerSiteNotificationKpisAsync` +Expected: PASS. + +**Step 5: Build the whole solution** to confirm the backend chain is clean. + +Run: `dotnet build ScadaLink.slnx` +Expected: Build succeeded, 0 warnings. + +**Step 6: Commit** + +```bash +git add src/ScadaLink.Communication/CommunicationService.cs \ + tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs +git commit -m "feat(notification-outbox): CommunicationService per-site KPI accessor" +``` + +--- + +## Task 6: Move SMTP Configuration page to /notifications/smtp + +**Files:** +- Move: `src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor` → `src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor` +- Move (if present): `Admin/SmtpConfiguration.razor.cs` → `Notifications/SmtpConfiguration.razor.cs` +- Modify: `tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs:30` + +**Context:** The page currently declares `@page "/admin/smtp"` and lives in the `Admin` Pages folder. Only the route and folder/namespace change — content and `RequireAdmin` policy stay. The Razor namespace follows the folder, so a moved `.razor` resolves to `ScadaLink.CentralUI.Components.Pages.Notifications`. There is no test that renders this page directly; the only reference is the Playwright `NavigationTests` inline data. + +**Step 1:** Create the `Components/Pages/Notifications/` folder by writing the moved file there. + +`git mv` the file: + +```bash +git mv src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor \ + src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor +``` + +If a code-behind `SmtpConfiguration.razor.cs` exists, `git mv` it too and update its `namespace` to `ScadaLink.CentralUI.Components.Pages.Notifications`. + +**Step 2:** In the moved `Notifications/SmtpConfiguration.razor`, change line 1 from: + +```razor +@page "/admin/smtp" +``` + +to: + +```razor +@page "/notifications/smtp" +``` + +**Step 3:** Update `tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs` line 30 — change the inline data from: + +```csharp +[InlineData("SMTP Configuration", "/admin/smtp")] +``` + +to: + +```csharp +[InlineData("SMTP Configuration", "/notifications/smtp")] +``` + +**Step 4: Build to verify** + +Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj` +Expected: Build succeeded, 0 warnings. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor \ + src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor \ + tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs +git commit -m "refactor(central-ui): move SMTP Configuration page to /notifications/smtp" +``` + +(Include the code-behind paths in `git add` if one was moved.) + +--- + +## Task 7: New Notification Lists page + +**Files:** +- Create: `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationLists.razor` +- Test: `tests/ScadaLink.CentralUI.Tests/Pages/NotificationListsPageTests.cs` + +**Context:** Notification lists are currently shown only inside the "Notification Lists" tab of `Components/Pages/Design/ExternalSystems.razor` (the `RenderNotificationLists` render fragment, lines ~301-380). This task creates a standalone page; Task 8 removes that tab. `INotificationRepository` exposes `GetAllNotificationListsAsync()`, `GetRecipientsByListIdAsync(int)`, `DeleteNotificationListAsync(int)`, `SaveChangesAsync()`. `NotificationList` has `Id` and `Name`; `NotificationRecipient` has `Name` and `EmailAddress`. The page is `RequireDesign`. Look at `NotificationOutboxPageTests.cs` for the bUnit `AuthorizeView` + DI wiring pattern (substituted repositories, a `TestAuthorizationContext`-style claims principal). + +**Step 1: Write the failing test** + +```csharp +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ScadaLink.Commons.Entities.Notifications; +using ScadaLink.Commons.Interfaces.Repositories; +using NotificationListsPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationLists; + +namespace ScadaLink.CentralUI.Tests.Pages; + +public class NotificationListsPageTests : BunitContext +{ + [Fact] + public void RendersNotificationListRows() + { + var repo = Substitute.For(); + repo.GetAllNotificationListsAsync() + .Returns(new List { new("Ops On-Call") { Id = 1 } }); + repo.GetRecipientsByListIdAsync(1) + .Returns(new List { new("Jane", "jane@example.com") }); + Services.AddSingleton(repo); + // wire IDialogService + an authorized Design-role principal — copy + // NotificationOutboxPageTests' auth/DI setup. + + var cut = Render(); + + Assert.Contains("Ops On-Call", cut.Markup); + } + + [Fact] + public void ShowsEmptyState_WhenNoLists() + { + var repo = Substitute.For(); + repo.GetAllNotificationListsAsync().Returns(new List()); + Services.AddSingleton(repo); + // ...same auth/DI setup... + + var cut = Render(); + + Assert.Contains("No notification lists", cut.Markup); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationListsPageTests` +Expected: FAIL — `NotificationLists` page does not exist. + +**Step 3: Write minimal implementation** + +`NotificationLists.razor`: + +```razor +@page "/notifications/lists" +@using ScadaLink.Security +@using ScadaLink.Commons.Entities.Notifications +@using ScadaLink.Commons.Interfaces.Repositories +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] +@inject INotificationRepository NotificationRepository +@inject NavigationManager NavigationManager +@inject IDialogService Dialog + +
+ + +
+

Notification Lists

+ +
+ + @if (_loading) + { + + } + else if (_errorMessage != null) + { +
@_errorMessage
+ } + else if (_lists.Count == 0) + { +
+
+
No notification lists
+ +
+
+ } + else + { +
+ + + + + + + + + + @foreach (var list in _lists) + { + var recipients = _recipients.GetValueOrDefault(list.Id) ?? new(); + + + + + + } + +
NameRecipientsActions
@list.Name + @if (recipients.Count == 0) + { + No recipients + } + else + { + @foreach (var r in recipients) + { + @r.Name <@r.EmailAddress> + } + } + + + +
+
+ } +
+ +@code { + private bool _loading = true; + private string? _errorMessage; + private List _lists = new(); + private readonly Dictionary> _recipients = new(); + private ToastNotification _toast = default!; + + protected override async Task OnInitializedAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _loading = true; + _errorMessage = null; + try + { + _lists = (await NotificationRepository.GetAllNotificationListsAsync()).ToList(); + _recipients.Clear(); + foreach (var list in _lists) + { + var recips = await NotificationRepository.GetRecipientsByListIdAsync(list.Id); + _recipients[list.Id] = recips.ToList(); + } + } + catch (Exception ex) + { + _errorMessage = ex.Message; + } + _loading = false; + } + + private async Task DeleteList(NotificationList list) + { + if (!await Dialog.ConfirmAsync("Delete", $"Delete notification list '{list.Name}'?", danger: true)) + { + return; + } + try + { + await NotificationRepository.DeleteNotificationListAsync(list.Id); + await NotificationRepository.SaveChangesAsync(); + _toast.ShowSuccess("Deleted."); + await LoadAsync(); + } + catch (Exception ex) + { + _toast.ShowError(ex.Message); + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationListsPageTests` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationLists.razor \ + tests/ScadaLink.CentralUI.Tests/Pages/NotificationListsPageTests.cs +git commit -m "feat(central-ui): standalone Notification Lists page" +``` + +--- + +## Task 8: Move the Notification List form route; drop the External Systems tab + +**Files:** +- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor` +- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor` + +**Context:** `NotificationListForm.razor` has two `@page` routes under `/design/notification-lists/...` and navigates back to `/design/external-systems` (`GoBack()` and the post-`Save()` redirect). `ExternalSystems.razor` is a 4-tab page ("Integration Definitions"): External Systems, Database Connections, **Notification Lists**, Inbound API Methods. Removing the Notification Lists tab leaves three. This task has no behavioral test of its own (Task 7 covers the new lists page); verify by build + a render-smoke check. + +**Step 1: Re-point the form routes.** In `NotificationListForm.razor`, change lines 1-2: + +```razor +@page "/design/notification-lists/create" +@page "/design/notification-lists/{Id:int}/edit" +``` + +to: + +```razor +@page "/notifications/lists/create" +@page "/notifications/lists/{Id:int}/edit" +``` + +**Step 2: Re-point the form's back-navigation.** In `NotificationListForm.razor`: +- In `Save()`, change `NavigationManager.NavigateTo("/design/external-systems");` to `NavigationManager.NavigateTo("/notifications/lists");` +- In `GoBack()`, change `NavigationManager.NavigateTo("/design/external-systems");` to `NavigationManager.NavigateTo("/notifications/lists");` + +**Step 3: Remove the Notification Lists tab from `ExternalSystems.razor`.** Delete, from that file: +- The tab `
  • ` button (the `_tab == "notif"` nav-item, ~lines 50-58). +- The tab-panel branch `else if (_tab == "notif") { ... }` (~lines 78-81). +- The `RenderNotificationLists()` render fragment method (~lines 301-373). +- The `DeleteNotifList(...)` method (~lines 375-380). +- The Notification Lists `@code` fields: `_notificationLists`, `_recipients`, `_notifSearch`, `FilteredNotificationLists` (~lines 110-117). +- In `LoadAllAsync()`, the lines that populate `_notificationLists` and the `_recipients` loop (~lines 141-148). +- The now-unused `@inject INotificationRepository NotificationRepository` (line 9) and `@using ScadaLink.Commons.Entities.Notifications` (line 4) — remove only if nothing else in the file still uses them (verify by search). + +**Step 4: Build to verify** + +Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj` +Expected: Build succeeded, 0 warnings. (A warning here usually means a leftover unused `using`/`@inject` — remove it.) + +**Step 5: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor \ + src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor +git commit -m "refactor(central-ui): move Notification List form to /notifications, drop External Systems tab" +``` + +--- + +## Task 9: New Notification Report page; retire the Outbox page + +**Files:** +- Create: `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor` +- Delete: `src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor` +- Rename: `tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs` → `NotificationReportPageTests.cs` + +**Context:** `Monitoring/NotificationOutbox.razor` currently combines KPI tiles (markup lines 23-72) with a filterable, paginated notifications table + Retry/Discard (lines 74-253). The new `NotificationReport.razor` keeps everything **except** the KPI tiles. Its existing test, `NotificationOutboxPageTests.cs`, renders the page via a scripted Akka actor seam and asserts on both KPI tiles and table rows. + +**Step 1: Create `NotificationReport.razor`** as a copy of `Monitoring/NotificationOutbox.razor` with these changes: +- Line 1 route: `@page "/monitoring/notification-outbox"` → `@page "/notifications/report"`. +- The `@inject ILogger Logger` type argument → `ILogger` (the class name follows the file). +- Page heading `

    Notification Outbox

    ` → `

    Notification Report

    `. +- **Delete the KPI tiles markup block** — the `@* ── KPI tiles ── *@` comment through the closing of its `else { ... }` (lines 23-72). +- **Delete the KPI `@code` members:** `_kpi`, `_kpiError`, and the `LoadKpis()` method. +- **Simplify `RefreshAll()`** — it currently does `await Task.WhenAll(LoadKpis(), FetchPage());`. Replace its body with `await FetchPage();` (keep the method name so the Retry/Discard callers are untouched), or inline `FetchPage()` at the three call sites and delete `RefreshAll()`. Keep `FormatAge` only if still referenced; if the KPI removal orphans it, delete it (build will flag it under `TreatWarningsAsErrors`). + +**Step 2: Delete the old page** + +```bash +git rm src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor +``` + +**Step 3: Migrate the test.** Rename the test file and update it: + +```bash +git mv tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs \ + tests/ScadaLink.CentralUI.Tests/Pages/NotificationReportPageTests.cs +``` + +In `NotificationReportPageTests.cs`: +- Rename the class to `NotificationReportPageTests`. +- Change the page alias: `using NotificationReportPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport;` and update render calls. +- **Delete the KPI-tile assertions** (any test asserting on Queue Depth / Stuck / Parked tile values, and the `_kpiReply` field if now unused — but `_queryReply`, retry/discard scripting stay). +- Keep the table-rendering, filter, pagination, and Retry/Discard tests. + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationReportPageTests` +Expected: PASS. + +Also build the UI project to confirm no dangling references to the deleted page: + +Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj` +Expected: Build succeeded, 0 warnings. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor \ + src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor \ + tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs \ + tests/ScadaLink.CentralUI.Tests/Pages/NotificationReportPageTests.cs +git commit -m "refactor(central-ui): split Notification Report out of the Outbox page" +``` + +--- + +## Task 10: New Notification KPIs page + +**Files:** +- Create: `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor` +- Test: `tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs` + +**Context:** This page shows the five global KPI tiles (lifted from the old Outbox page, markup lines 30-71) plus a per-source-site breakdown table backed by `CommunicationService.GetPerSiteNotificationKpisAsync` (Task 5). `CommunicationService` is a concrete class with non-virtual methods, so tests drive it through the scripted-actor seam — copy the `ActorSystem` + scripted `ReceiveActor` + `SetNotificationOutbox` setup verbatim from `NotificationReportPageTests.cs` (Task 9). The scripted actor must answer **both** `NotificationKpiRequest` and `PerSiteNotificationKpiRequest`. Site friendly-names come from `ISiteRepository.GetAllSitesAsync()` (`Site.SiteIdentifier` / `Site.Name`), exactly as the old Outbox page resolved the source-site filter. + +**Step 1: Write the failing test** + +```csharp +// Copy the ActorSystem / scripted-actor / CommunicationService / ISiteRepository / auth +// wiring from NotificationReportPageTests. Script the actor to reply: +// NotificationKpiRequest -> a NotificationKpiResponse with known tile values +// PerSiteNotificationKpiRequest -> a PerSiteNotificationKpiResponse with one site row + +[Fact] +public void RendersGlobalTilesAndPerSiteRows() +{ + // _kpiReply: QueueDepth 7, StuckCount 2, ParkedCount 1, DeliveredLastInterval 42 + // _perSiteReply: [ SiteNotificationKpiSnapshot("plant-a", 4, 1, 0, 9, 7m) ] + var cut = Render(); + + Assert.Contains("Queue Depth", cut.Markup); + Assert.Contains("7", cut.Markup); // global tile value + Assert.Contains("plant-a", cut.Markup); // per-site row +} + +[Fact] +public void ShowsKpiError_WhenGlobalKpiQueryFails() +{ + // script NotificationKpiRequest -> response with Success:false, ErrorMessage:"kpi down" + var cut = Render(); + Assert.Contains("kpi down", cut.Markup); +} + +[Fact] +public void ShowsPerSiteEmptyState_WhenNoSites() +{ + // script PerSiteNotificationKpiRequest -> Success:true, empty Sites + var cut = Render(); + Assert.Contains("No per-site activity", cut.Markup); +} +``` + +Use `using NotificationKpisPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationKpis;`. + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationKpisPageTests` +Expected: FAIL — `NotificationKpis` page does not exist. + +**Step 3: Write minimal implementation** + +`NotificationKpis.razor`: + +```razor +@page "/notifications/kpis" +@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)] +@using ScadaLink.Commons.Entities.Sites +@using ScadaLink.Commons.Interfaces.Repositories +@using ScadaLink.Commons.Messages.Notification +@using ScadaLink.Commons.Types.Notifications +@using ScadaLink.Communication +@inject CommunicationService CommunicationService +@inject ISiteRepository SiteRepository +@inject ILogger Logger + +
    +
    +

    Notification KPIs

    + +
    + + @* ── Global KPI tiles ── *@ + @if (_kpiError != null) + { +
    KPIs unavailable: @_kpiError
    + } + else + { +
    +
    +
    +
    +

    @_kpi.QueueDepth

    + Queue Depth +
    +
    +
    +
    +
    +
    +

    @_kpi.StuckCount

    + Stuck +
    +
    +
    +
    +
    +
    +

    @_kpi.ParkedCount

    + Parked +
    +
    +
    +
    +
    +
    +

    @_kpi.DeliveredLastInterval

    + Delivered (last interval) +
    +
    +
    +
    +
    +
    +

    @FormatAge(_kpi.OldestPendingAge)

    + Oldest Pending Age +
    +
    +
    +
    + } + + @* ── Per-site breakdown ── *@ +
    Per-site breakdown
    + @if (_perSiteError != null) + { +
    Per-site KPIs unavailable: @_perSiteError
    + } + else if (_perSite.Count == 0) + { +
    +
    +
    No per-site activity.
    +
    +
    + } + else + { +
    + + + + + + + + + + + + + @foreach (var s in _perSite) + { + + + + + + + + + } + +
    SiteQueue DepthStuckParkedDelivered (last interval)Oldest Pending Age
    @SiteName(s.SourceSiteId)@s.QueueDepth@s.StuckCount@s.ParkedCount@s.DeliveredLastInterval@FormatAge(s.OldestPendingAge)
    +
    + } +
    + +@code { + private List _sites = new(); + + private NotificationKpiResponse _kpi = new(string.Empty, true, null, 0, 0, 0, 0, null); + private string? _kpiError; + + private IReadOnlyList _perSite = Array.Empty(); + private string? _perSiteError; + + private bool _loading; + + protected override async Task OnInitializedAsync() + { + try + { + _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); + } + catch (Exception ex) + { + // Non-fatal — the per-site table falls back to raw site identifiers. + Logger.LogWarning(ex, "Failed to load sites for the KPI per-site breakdown."); + } + + await RefreshAll(); + } + + private async Task RefreshAll() + { + _loading = true; + // Race-free despite both tasks mutating component fields: Blazor Server runs + // every continuation on the circuit's single-threaded synchronization context. + await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis()); + _loading = false; + } + + private async Task LoadGlobalKpis() + { + try + { + var response = await CommunicationService.GetNotificationKpisAsync( + new NotificationKpiRequest(Guid.NewGuid().ToString("N"))); + if (response.Success) + { + _kpi = response; + _kpiError = null; + } + else + { + _kpiError = response.ErrorMessage ?? "KPI query failed."; + } + } + catch (Exception ex) + { + _kpiError = $"KPI query failed: {ex.Message}"; + } + } + + private async Task LoadPerSiteKpis() + { + try + { + var response = await CommunicationService.GetPerSiteNotificationKpisAsync( + new PerSiteNotificationKpiRequest(Guid.NewGuid().ToString("N"))); + if (response.Success) + { + _perSite = response.Sites; + _perSiteError = null; + } + else + { + _perSiteError = response.ErrorMessage ?? "Per-site KPI query failed."; + } + } + catch (Exception ex) + { + _perSiteError = $"Per-site KPI query failed: {ex.Message}"; + } + } + + private string SiteName(string siteId) => + _sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId; + + private static string FormatAge(TimeSpan? age) + { + if (age == null) return "—"; + var t = age.Value; + if (t.TotalSeconds < 60) return $"{(int)t.TotalSeconds}s"; + if (t.TotalMinutes < 60) return $"{(int)t.TotalMinutes}m"; + if (t.TotalHours < 24) return $"{(int)t.TotalHours}h"; + return $"{(int)t.TotalDays}d"; + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationKpisPageTests` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor \ + tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs +git commit -m "feat(central-ui): Notification KPIs page with per-site breakdown" +``` + +--- + +## Task 11: NavMenu — add the Notifications section + +**Files:** +- Modify: `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor` +- Test: `tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs` (create) + +**Context:** `NavMenu.razor` builds the sidebar. It has gated sections (Admin / Design / Deployment each wrapped in one `AuthorizeView Policy=...`) and a Monitoring section with per-item gating. Two existing items must move out: the **SMTP Configuration** `
  • ` in the Admin section and the **Notification Outbox** `
  • ` in the Monitoring section. The new Notifications section goes **between the Deployment section and the Monitoring section**. Each item is wrapped in its own per-item `AuthorizeView` (the Monitoring section already does this — copy that shape). The plain-`div` section header needs no gating: every authenticated user holds at least one of Admin/Design/Deployment. + +**Step 1: Write the failing test** + +bUnit-render `NavMenu` under different role principals. Copy the authorization wiring from an existing nav/auth test (e.g. `Monitoring/MonitoringAuthorizationTests.cs` or `NotificationReportPageTests`). + +```csharp +using Bunit; +using ScadaLink.Security; +using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu; + +namespace ScadaLink.CentralUI.Tests.Layout; + +public class NavMenuTests : BunitContext +{ + [Fact] + public void NotificationsSection_ShowsAllItems_ForMultiRoleUser() + { + // authorize a principal holding Admin + Design + Deployment roles + var cut = Render(); // with the authorized principal wired in + + Assert.Contains("Notifications", cut.Markup); + Assert.Contains("/notifications/smtp", cut.Markup); + Assert.Contains("/notifications/lists", cut.Markup); + Assert.Contains("/notifications/report", cut.Markup); + Assert.Contains("/notifications/kpis", cut.Markup); + } + + [Fact] + public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp() + { + // authorize a principal holding only the Admin role + var cut = Render(); + + Assert.Contains("/notifications/smtp", cut.Markup); + Assert.DoesNotContain("/notifications/report", cut.Markup); + Assert.DoesNotContain("/notifications/lists", cut.Markup); + } + + [Fact] + public void OldRoutes_AreNoLongerLinked() + { + // authorize a multi-role principal + var cut = Render(); + + Assert.DoesNotContain("/admin/smtp", cut.Markup); + Assert.DoesNotContain("/monitoring/notification-outbox", cut.Markup); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NavMenuTests` +Expected: FAIL — old routes still present, new section absent. + +**Step 3: Write minimal implementation** + +In `NavMenu.razor`: + +1. **Remove** the SMTP `
  • ` from the Admin section: + ```razor +
  • + ``` + +2. **Remove** the Notification Outbox `
  • ` from the Monitoring section: + ```razor +
  • + ``` + +3. **Insert the Notifications section** between the closing of the Deployment `AuthorizeView` and the Monitoring `` line: + + ```razor + @* Notifications — mixed-role section; each item gated by its own policy. + The header is ungated: every authenticated user holds at least one of + Admin/Design/Deployment, so it always has a visible child. *@ + + + + + + + + + + + + + + + + + + ``` + + Each `` needs a unique context name (nested `AuthorizeView`s require it — the file already uses this pattern). + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NavMenuTests` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor \ + tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs +git commit -m "feat(central-ui): add the Notifications nav section" +``` + +--- + +## Task 12: Health dashboard — link to the KPI page + +**Files:** +- Modify: `src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor` +- Test: `tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs` (add a case) + +**Context:** `Health.razor` keeps a "Notification Outbox" headline tile row (`
    Notification Outbox
    ` at line ~24, three tiles below). Add a "View details →" link to `/notifications/kpis` next to that heading. The tiles and their data path are unchanged. + +**Step 1: Write the failing test** + +Add to `HealthPageTests.cs`, following its existing render setup: + +```csharp +[Fact] +public void RendersLinkToTheNotificationKpisPage() +{ + var cut = Render(); // match the existing HealthPageTests render call + Assert.Contains("/notifications/kpis", cut.Markup); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter RendersLinkToTheNotificationKpisPage` +Expected: FAIL — no such link. + +**Step 3: Write minimal implementation** + +In `Health.razor`, replace the bare heading line: + +```razor +
    Notification Outbox
    +``` + +with a heading row carrying the link: + +```razor +
    +
    Notification Outbox
    + View details → +
    +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter HealthPageTests` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor \ + tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs +git commit -m "feat(central-ui): link Health outbox tiles to the Notification KPIs page" +``` + +--- + +## Task 13: Full build + suite verification + +**Files:** none (verification only). + +**Step 1: Clean build** + +Run: `dotnet build ScadaLink.slnx` +Expected: Build succeeded, **0 warnings** (`TreatWarningsAsErrors` is on). + +**Step 2: Full test suite** + +Run: `dotnet test ScadaLink.slnx` +Expected: all test projects green. If `SandboxTests.Sandbox_LongRunningScript_TimesOut` or an `InstanceActorChildAttributeRace` test fails, that is a known pre-existing CPU-timing flake under parallel load — re-run `tests/ScadaLink.SiteRuntime.Tests` in isolation to confirm it is not a regression from this work. + +**Step 3: Grep for stale references** + +Run: +```bash +grep -rn "monitoring/notification-outbox\|admin/smtp\|design/notification-lists" \ + src tests --include="*.razor" --include="*.cs" | grep -v "/obj/" | grep -v "/bin/" +``` +Expected: **no matches.** Any hit is a stale link to fix before finishing. + +**Step 4: Commit (only if Step 3 required fixes)** + +```bash +git add +git commit -m "fix(central-ui): clear stale notification route references" +``` + +If no fixes were needed, there is nothing to commit — the work is complete. + +--- + +## Follow-ups (not in scope, track separately) + +- Per-site KPI **trend** charts would need a time-series store — deliberately omitted (no historical persistence; KPIs are point-in-time). +- The `Notification Outbox` **component** name (#21 in CLAUDE.md) is unchanged; only UI page names changed. If the component is ever renamed, update CLAUDE.md and `README.md` together. diff --git a/docs/plans/2026-05-19-notifications-nav-group.md.tasks.json b/docs/plans/2026-05-19-notifications-nav-group.md.tasks.json new file mode 100644 index 0000000..f96efac --- /dev/null +++ b/docs/plans/2026-05-19-notifications-nav-group.md.tasks.json @@ -0,0 +1,19 @@ +{ + "planPath": "docs/plans/2026-05-19-notifications-nav-group.md", + "tasks": [ + {"id": 53, "subject": "Task 1: Per-site KPI domain type + repo contract", "status": "pending"}, + {"id": 54, "subject": "Task 2: ComputePerSiteKpisAsync repository impl", "status": "pending", "blockedBy": [53]}, + {"id": 55, "subject": "Task 3: Per-site KPI message contracts", "status": "pending", "blockedBy": [53]}, + {"id": 56, "subject": "Task 4: Actor per-site KPI handler", "status": "pending", "blockedBy": [54, 55]}, + {"id": 57, "subject": "Task 5: CommunicationService per-site KPI accessor", "status": "pending", "blockedBy": [56]}, + {"id": 58, "subject": "Task 6: Move SMTP page to /notifications/smtp", "status": "pending"}, + {"id": 59, "subject": "Task 7: New Notification Lists page", "status": "pending"}, + {"id": 60, "subject": "Task 8: Move list form route; drop External Systems tab", "status": "pending", "blockedBy": [59]}, + {"id": 61, "subject": "Task 9: New Notification Report page; retire Outbox page", "status": "pending"}, + {"id": 62, "subject": "Task 10: New Notification KPIs page", "status": "pending", "blockedBy": [57]}, + {"id": 63, "subject": "Task 11: NavMenu Notifications section", "status": "pending", "blockedBy": [58, 59, 61, 62]}, + {"id": 64, "subject": "Task 12: Health dashboard KPI page link", "status": "pending", "blockedBy": [62]}, + {"id": 65, "subject": "Task 13: Full build + suite verification", "status": "pending", "blockedBy": [53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64]} + ], + "lastUpdated": "2026-05-19" +}