feat(notif): NotificationDetailRequest query for full notification detail

This commit is contained in:
Joseph Doherty
2026-05-21 02:47:43 -04:00
parent 8fd0cf355b
commit 194cae2fbf
4 changed files with 155 additions and 0 deletions

View File

@@ -76,6 +76,49 @@ public record DiscardNotificationResponse(
bool Success, bool Success,
string? ErrorMessage); 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> /// <summary>
/// Outbox UI -> Central: request for the notification outbox KPI summary. /// Outbox UI -> Central: request for the notification outbox KPI summary.
/// </summary> /// </summary>

View File

@@ -275,6 +275,13 @@ public class CommunicationService
request, _options.QueryTimeout, cancellationToken); 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( public async Task<NotificationKpiResponse> GetNotificationKpisAsync(
NotificationKpiRequest request, CancellationToken cancellationToken = default) NotificationKpiRequest request, CancellationToken cancellationToken = default)
{ {

View File

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

View File

@@ -306,6 +306,57 @@ public class NotificationOutboxActorQueryTests : TestKit
Assert.Contains("not found", response.ErrorMessage); 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] [Fact]
public void KpiRequest_ComputesKpis_AndMapsSnapshot() public void KpiRequest_ComputesKpis_AndMapsSnapshot()
{ {