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()
{