Merge branch 'feature/notification-detail-body-recipient': detail modal shows body + recipients

The /notifications/report detail modal showed only NotificationSummary
fields. Added a NotificationDetailRequest query (mirrors the existing
outbox query plumbing — Commons contract + NotificationOutboxActor
handler + CommunicationService method) that fetches the full Notification
entity. The modal now fetches on open and renders the message Body
(preformatted plain text, never MarkupString) and resolved recipients
(ResolvedTargets, with a list-name fallback note when not yet resolved).
6 new tests.
This commit is contained in:
Joseph Doherty
2026-05-21 02:52:26 -04:00
6 changed files with 398 additions and 2 deletions

View File

@@ -268,6 +268,64 @@
<dd class="col-sm-9 text-danger">@d.LastError</dd>
}
</dl>
@* ── Recipients ── *@
<hr />
<h6 class="mb-2">Recipients</h6>
@if (_detailLoading)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Loading details…
</div>
}
else if (_detailError != null)
{
<div class="text-danger small">@_detailError</div>
}
else if (_detail != null)
{
var recipients = ParseRecipients(_detail.ResolvedTargets);
if (recipients.Count > 0)
{
<ul class="mb-0">
@foreach (var recipient in recipients)
{
<li>@recipient</li>
}
</ul>
}
else
{
<div class="text-muted small">
Not yet resolved — recipients are resolved from list
"@d.ListName" at delivery time.
</div>
}
}
@* ── Body ── *@
<hr />
<h6 class="mb-2">Message body</h6>
@if (_detailLoading)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Loading details…
</div>
}
else if (_detailError != null)
{
<div class="text-danger small">@_detailError</div>
}
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. *@
<pre class="border rounded bg-light p-2 mb-0"
style="max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word;">@_detail.Body</pre>
}
</div>
<div class="modal-footer">
@if (d.Status == "Parked")
@@ -304,6 +362,9 @@
// Row detail modal
private NotificationSummary? _detailNotification;
private NotificationDetail? _detail;
private bool _detailLoading;
private string? _detailError;
// Filters
private string _statusFilter = string.Empty;
@@ -440,9 +501,80 @@
_actionInProgress = false;
}
private void ShowDetail(NotificationSummary n) => _detailNotification = n;
private async Task ShowDetail(NotificationSummary n)
{
// The summary fields render immediately; Body + recipients fill in once the
// full-detail fetch completes.
_detailNotification = n;
_detail = null;
_detailError = null;
_detailLoading = true;
StateHasChanged();
private void CloseDetail() => _detailNotification = null;
try
{
var response = await CommunicationService.GetNotificationDetailAsync(
new NotificationDetailRequest(Guid.NewGuid().ToString("N"), n.NotificationId));
if (response.Success && response.Detail != null)
{
_detail = response.Detail;
}
else
{
_detailError = response.ErrorMessage ?? "Failed to load notification detail.";
}
}
catch (Exception ex)
{
_detailError = $"Failed to load notification detail: {ex.Message}";
}
_detailLoading = false;
}
private void CloseDetail()
{
_detailNotification = null;
_detail = null;
_detailError = null;
_detailLoading = false;
}
/// <summary>
/// Best-effort parse of <c>ResolvedTargets</c> into individual recipient addresses.
/// The field may be a JSON string array, or a comma/semicolon-separated string.
/// Returns an empty list when null/empty.
/// </summary>
private static List<string> ParseRecipients(string? resolvedTargets)
{
if (string.IsNullOrWhiteSpace(resolvedTargets))
{
return new List<string>();
}
var trimmed = resolvedTargets.Trim();
if (trimmed.StartsWith('['))
{
try
{
var parsed = System.Text.Json.JsonSerializer.Deserialize<List<string>>(trimmed);
if (parsed != null)
{
return parsed
.Where(r => !string.IsNullOrWhiteSpace(r))
.Select(r => r.Trim())
.ToList();
}
}
catch (System.Text.Json.JsonException)
{
// Not valid JSON — fall through to the delimiter-split path.
}
}
return trimmed
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
}
private async Task RetryFromDetail(NotificationSummary n)
{

View File

@@ -76,6 +76,49 @@ public record DiscardNotificationResponse(
bool Success,
string? ErrorMessage);
/// <summary>
/// Outbox UI -> Central: request for the full detail of a single notification
/// (including Body and resolved recipients), for the report detail modal.
/// </summary>
public record NotificationDetailRequest(
string CorrelationId,
string NotificationId);
/// <summary>
/// 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.
/// </summary>
public record NotificationDetailResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
NotificationDetail? Detail);
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// Outbox UI -> Central: request for the notification outbox KPI summary.
/// </summary>

View File

@@ -275,6 +275,13 @@ public class CommunicationService
request, _options.QueryTimeout, cancellationToken);
}
public async Task<NotificationDetailResponse> GetNotificationDetailAsync(
NotificationDetailRequest request, CancellationToken cancellationToken = default)
{
return await GetNotificationOutbox().Ask<NotificationDetailResponse>(
request, _options.QueryTimeout, cancellationToken);
}
public async Task<NotificationKpiResponse> GetNotificationKpisAsync(
NotificationKpiRequest request, CancellationToken cancellationToken = default)
{

View File

@@ -66,6 +66,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
Receive<InternalMessages.PurgeComplete>(_ => { });
Receive<NotificationOutboxQueryRequest>(HandleQuery);
Receive<NotificationStatusQuery>(HandleStatusQuery);
Receive<NotificationDetailRequest>(HandleDetailRequest);
Receive<RetryNotificationRequest>(HandleRetry);
Receive<DiscardNotificationRequest>(HandleDiscard);
Receive<NotificationKpiRequest>(HandleKpiRequest);
@@ -674,6 +675,59 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
DeliveredAt: notification.DeliveredAt);
}
/// <summary>
/// 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.
/// </summary>
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<NotificationDetailResponse> DetailAsync(NotificationDetailRequest request)
{
using var scope = _serviceProvider.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
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);
}
/// <summary>
/// Handles a manual retry request. Only a <c>Parked</c> notification can be retried;
/// it is reset to <c>Pending</c> with a cleared retry count, next-attempt time, and

View File

@@ -29,6 +29,27 @@ public class NotificationReportDetailModalTests : BunitContext
private readonly ActorSystem _system = ActorSystem.Create("notif-report-modal-tests");
private readonly CommunicationService _comms;
private NotificationDetailResponse _detailReply =
new("d", true, null, new NotificationDetail(
NotificationId: "notif-aaaaaaaa-1111-full-id",
Type: "Email",
ListName: "Ops On-Call",
Subject: "Pump fault at Plant-A",
Body: "Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
Status: "Parked",
RetryCount: 3,
LastError: "SMTP timeout connecting to mail relay",
ResolvedTargets: "[\"ops@example.com\",\"oncall@example.com\"]",
TypeData: null,
SourceSiteId: "plant-a",
SourceInstanceId: "Pump-001",
SourceScript: "PumpFault.csx",
SiteEnqueuedAt: DateTimeOffset.UtcNow.AddMinutes(-31),
CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
LastAttemptAt: DateTimeOffset.UtcNow.AddMinutes(-5),
NextAttemptAt: null,
DeliveredAt: null));
private NotificationOutboxQueryResponse _queryReply =
new("q", true, null, new List<NotificationSummary>
{
@@ -149,6 +170,90 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
[Fact]
public void Modal_FetchesAndShowsBody()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains(
"Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
modal.TextContent);
});
}
[Fact]
public void Modal_ShowsRecipients_FromResolvedTargets()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains("ops@example.com", modal.TextContent);
Assert.Contains("oncall@example.com", modal.TextContent);
});
}
[Fact]
public void Modal_ShowsListFallback_WhenResolvedTargetsNull()
{
_detailReply = _detailReply with
{
Detail = _detailReply.Detail! with { ResolvedTargets = null },
};
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains("Not yet resolved", modal.TextContent);
Assert.Contains("Ops On-Call", modal.TextContent);
Assert.Contains("at delivery time", modal.TextContent);
});
}
[Fact]
public void Modal_ShowsError_WhenDetailFetchFails()
{
_detailReply = new NotificationDetailResponse("d", false, "detail store unavailable", null);
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
// The error surfaces in the body/recipient sections...
Assert.Contains("detail store unavailable", modal.TextContent);
// ...but the summary fields (from the grid row) still render.
Assert.Contains("Ops On-Call", modal.TextContent);
Assert.Contains("notif-aaaaaaaa-1111-full-id", modal.TextContent);
});
}
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -163,6 +268,10 @@ public class NotificationReportDetailModalTests : BunitContext
public ScriptedOutboxActor(NotificationReportDetailModalTests test)
{
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
Receive<NotificationDetailRequest>(r => Sender.Tell(test._detailReply with
{
CorrelationId = r.CorrelationId,
}));
Receive<RetryNotificationRequest>(r =>
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)));
Receive<DiscardNotificationRequest>(r =>

View File

@@ -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<CancellationToken>()).Returns(row);
var actor = CreateActor();
actor.Tell(new NotificationDetailRequest("corr-d1", row.NotificationId), TestActor);
var response = ExpectMsg<NotificationDetailResponse>();
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<string>(), Arg.Any<CancellationToken>())
.Returns((Notification?)null);
var actor = CreateActor();
actor.Tell(new NotificationDetailRequest("corr-d2", "missing-id"), TestActor);
var response = ExpectMsg<NotificationDetailResponse>();
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()
{