From 194cae2fbf4a852121b35379c584fbd857ed1457 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 02:47:43 -0400 Subject: [PATCH 1/2] feat(notif): NotificationDetailRequest query for full notification detail --- .../Notification/NotificationOutboxQueries.cs | 43 +++++++++++++++ .../CommunicationService.cs | 7 +++ .../NotificationOutboxActor.cs | 54 +++++++++++++++++++ .../NotificationOutboxActorQueryTests.cs | 51 ++++++++++++++++++ 4 files changed, 155 insertions(+) diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs index 20f68d9..12151d2 100644 --- a/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs @@ -76,6 +76,49 @@ public record DiscardNotificationResponse( bool Success, string? ErrorMessage); +/// +/// Outbox UI -> Central: request for the full detail of a single notification +/// (including Body and resolved recipients), for the report detail modal. +/// +public record NotificationDetailRequest( + string CorrelationId, + string NotificationId); + +/// +/// Central -> Outbox UI: full detail for one notification. On a repository fault or +/// missing row, Success is false / Detail is null and ErrorMessage carries the cause. +/// +public record NotificationDetailResponse( + string CorrelationId, + bool Success, + string? ErrorMessage, + NotificationDetail? Detail); + +/// +/// Full notification detail for the report detail modal — everything in the grid's +/// NotificationSummary plus Body, ResolvedTargets (recipients), TypeData, SourceScript, +/// and the additional lifecycle timestamps. +/// +public record NotificationDetail( + string NotificationId, + string Type, + string ListName, + string Subject, + string Body, + string Status, + int RetryCount, + string? LastError, + string? ResolvedTargets, + string? TypeData, + string SourceSiteId, + string? SourceInstanceId, + string? SourceScript, + DateTimeOffset SiteEnqueuedAt, + DateTimeOffset CreatedAt, + DateTimeOffset? LastAttemptAt, + DateTimeOffset? NextAttemptAt, + DateTimeOffset? DeliveredAt); + /// /// Outbox UI -> Central: request for the notification outbox KPI summary. /// diff --git a/src/ScadaLink.Communication/CommunicationService.cs b/src/ScadaLink.Communication/CommunicationService.cs index 7740fbb..a6ea2c7 100644 --- a/src/ScadaLink.Communication/CommunicationService.cs +++ b/src/ScadaLink.Communication/CommunicationService.cs @@ -275,6 +275,13 @@ public class CommunicationService request, _options.QueryTimeout, cancellationToken); } + public async Task GetNotificationDetailAsync( + NotificationDetailRequest request, CancellationToken cancellationToken = default) + { + return await GetNotificationOutbox().Ask( + request, _options.QueryTimeout, cancellationToken); + } + public async Task GetNotificationKpisAsync( NotificationKpiRequest request, CancellationToken cancellationToken = default) { diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs index 400f6d2..eeb561c 100644 --- a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs @@ -66,6 +66,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers Receive(_ => { }); Receive(HandleQuery); Receive(HandleStatusQuery); + Receive(HandleDetailRequest); Receive(HandleRetry); Receive(HandleDiscard); Receive(HandleKpiRequest); @@ -674,6 +675,59 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers DeliveredAt: notification.DeliveredAt); } + /// + /// Handles a full-detail query for a single notification — backs the report detail + /// modal, which needs the Body and resolved recipients that the grid summary omits. + /// + private void HandleDetailRequest(NotificationDetailRequest request) + { + var sender = Sender; + + DetailAsync(request).PipeTo( + sender, + success: response => response, + failure: ex => new NotificationDetailResponse( + request.CorrelationId, Success: false, + ErrorMessage: ex.GetBaseException().Message, Detail: null)); + } + + private async Task DetailAsync(NotificationDetailRequest request) + { + using var scope = _serviceProvider.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var notification = await repository.GetByIdAsync(request.NotificationId); + + if (notification is null) + { + return new NotificationDetailResponse( + request.CorrelationId, Success: false, + ErrorMessage: "notification not found", Detail: null); + } + + var detail = new NotificationDetail( + notification.NotificationId, + notification.Type.ToString(), + notification.ListName, + notification.Subject, + notification.Body, + notification.Status.ToString(), + notification.RetryCount, + notification.LastError, + notification.ResolvedTargets, + notification.TypeData, + notification.SourceSiteId, + notification.SourceInstanceId, + notification.SourceScript, + notification.SiteEnqueuedAt, + notification.CreatedAt, + notification.LastAttemptAt, + notification.NextAttemptAt, + notification.DeliveredAt); + + return new NotificationDetailResponse( + request.CorrelationId, Success: true, ErrorMessage: null, detail); + } + /// /// Handles a manual retry request. Only a Parked notification can be retried; /// it is reset to Pending with a cleared retry count, next-attempt time, and diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs index 5bc9052..9c2860b 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs @@ -306,6 +306,57 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Contains("not found", response.ErrorMessage); } + [Fact] + public void DetailRequest_KnownId_ReturnsFullDetail_WithBodyAndResolvedTargets() + { + var row = MakeNotification( + status: NotificationStatus.Delivered, retryCount: 2, lastError: "transient blip"); + row.Body = "Tank-7 has exceeded its high-level setpoint."; + row.ResolvedTargets = "[\"ops@example.com\",\"oncall@example.com\"]"; + row.TypeData = "{\"priority\":\"high\"}"; + row.SourceScript = "HighLevelAlarm.csx"; + row.SiteEnqueuedAt = DateTimeOffset.UtcNow.AddMinutes(-5); + row.DeliveredAt = DateTimeOffset.UtcNow; + _repository.GetByIdAsync(row.NotificationId, Arg.Any()).Returns(row); + var actor = CreateActor(); + + actor.Tell(new NotificationDetailRequest("corr-d1", row.NotificationId), TestActor); + + var response = ExpectMsg(); + Assert.Equal("corr-d1", response.CorrelationId); + Assert.True(response.Success); + Assert.Null(response.ErrorMessage); + Assert.NotNull(response.Detail); + var detail = response.Detail!; + Assert.Equal(row.NotificationId, detail.NotificationId); + Assert.Equal("Email", detail.Type); + Assert.Equal("Delivered", detail.Status); + Assert.Equal("Tank-7 has exceeded its high-level setpoint.", detail.Body); + Assert.Equal("[\"ops@example.com\",\"oncall@example.com\"]", detail.ResolvedTargets); + Assert.Equal("{\"priority\":\"high\"}", detail.TypeData); + Assert.Equal("HighLevelAlarm.csx", detail.SourceScript); + Assert.Equal("instance-42", detail.SourceInstanceId); + Assert.Equal(2, detail.RetryCount); + Assert.Equal("transient blip", detail.LastError); + } + + [Fact] + public void DetailRequest_UnknownId_ReturnsNotFound() + { + _repository.GetByIdAsync(Arg.Any(), Arg.Any()) + .Returns((Notification?)null); + var actor = CreateActor(); + + actor.Tell(new NotificationDetailRequest("corr-d2", "missing-id"), TestActor); + + var response = ExpectMsg(); + Assert.Equal("corr-d2", response.CorrelationId); + Assert.False(response.Success); + Assert.Null(response.Detail); + Assert.NotNull(response.ErrorMessage); + Assert.Contains("not found", response.ErrorMessage); + } + [Fact] public void KpiRequest_ComputesKpis_AndMapsSnapshot() { From babf5b99e7473e3ce15b82b0f5ddc50c6e751a73 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 02:49:17 -0400 Subject: [PATCH 2/2] feat(ui): notification detail modal shows message body + recipients --- .../Notifications/NotificationReport.razor | 136 +++++++++++++++++- .../NotificationReportDetailModalTests.cs | 109 ++++++++++++++ 2 files changed, 243 insertions(+), 2 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor index 7b2b491..44798ce 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor @@ -268,6 +268,64 @@
@d.LastError
} + + @* ── Recipients ── *@ +
+
Recipients
+ @if (_detailLoading) + { +
+ + Loading details… +
+ } + else if (_detailError != null) + { +
@_detailError
+ } + else if (_detail != null) + { + var recipients = ParseRecipients(_detail.ResolvedTargets); + if (recipients.Count > 0) + { +
    + @foreach (var recipient in recipients) + { +
  • @recipient
  • + } +
+ } + else + { +
+ Not yet resolved — recipients are resolved from list + "@d.ListName" at delivery time. +
+ } + } + + @* ── Body ── *@ +
+
Message body
+ @if (_detailLoading) + { +
+ + Loading details… +
+ } + else if (_detailError != null) + { +
@_detailError
+ } + else if (_detail != null) + { + @* Email bodies are plain text (design: BCC delivery, plain text). + Rendered as preformatted text — never as a MarkupString, which + would be an XSS vector. *@ +
@_detail.Body
+ }