feat(notification-outbox): async Notify.Send with status handle

Notify.To(list).Send(subject,body) now generates a NotificationId GUID,
enqueues a Notification-category message into the site Store-and-Forward
Engine, and returns the NotificationId immediately (Task<string>). The
NotificationId is the single idempotency key end-to-end: it is the S&F
message Id, it is carried inside the buffered NotificationSubmit payload,
and it is the id the forwarder submits to central.

NotificationForwarder now deserializes the buffered payload as a
NotificationSubmit and reads NotificationId from it (re-stamping only the
site-owned SourceSiteId / SourceInstanceId), instead of deriving the id
from StoreAndForwardMessage.Id.

Adds NotifyHelper.Status(id): queries central via the site communication
actor; reports the site-local Forwarding state while the notification is
still buffered at the site, maps central's response when found, and
Unknown otherwise. Adds a NotificationDeliveryStatus record.

SiteCommunicationActor gains a NotificationStatusQuery forwarding handler
mirroring NotificationSubmit. StoreAndForwardService.EnqueueAsync gains an
optional messageId parameter and exposes GetMessageByIdAsync.
This commit is contained in:
Joseph Doherty
2026-05-19 02:30:51 -04:00
parent 05614e037a
commit 3326bddeb0
9 changed files with 562 additions and 77 deletions

View File

@@ -18,19 +18,26 @@ public class NotificationForwarderTests : TestKit
/// <summary>
/// Builds a buffered notification S&amp;F message whose payload matches the shape
/// produced by the site NotificationDeliveryService enqueue path.
/// produced by the site <c>Notify.Send</c> enqueue path (Task 19): a serialized
/// <see cref="NotificationSubmit"/> carrying a script-generated
/// <see cref="NotificationSubmit.NotificationId"/>. The S&amp;F message
/// <see cref="StoreAndForwardMessage.Id"/> equals that same id.
/// </summary>
private static StoreAndForwardMessage BufferedNotification(
string id = "msg-1", string listName = "Operators",
string subject = "Pump alarm", string message = "Pump 3 tripped",
string? originInstance = "Plant.Pump3")
string? originInstance = "Plant.Pump3", string? sourceScript = "alarmScript")
{
var payload = JsonSerializer.Serialize(new
{
ListName = listName,
Subject = subject,
Message = message
});
var payload = JsonSerializer.Serialize(new NotificationSubmit(
NotificationId: id,
ListName: listName,
Subject: subject,
Body: message,
// SourceSiteId is re-stamped by the forwarder; the enqueue side leaves it blank.
SourceSiteId: string.Empty,
SourceInstanceId: originInstance,
SourceScript: sourceScript,
SiteEnqueuedAt: DateTimeOffset.UtcNow));
return new StoreAndForwardMessage
{
Id = id,
@@ -57,11 +64,15 @@ public class NotificationForwarderTests : TestKit
// The central target receives a NotificationSubmit whose fields map from the
// buffered payload; reply Accepted so the handler completes as delivered.
var submit = centralProbe.ExpectMsg<NotificationSubmit>();
Assert.Equal("msg-1", submit.NotificationId);
Assert.Equal("Operators", submit.ListName);
Assert.Equal("Pump alarm", submit.Subject);
Assert.Equal("Pump 3 tripped", submit.Body);
// SourceSiteId is re-stamped by the forwarder from its own site id.
Assert.Equal("site-7", submit.SourceSiteId);
Assert.Equal("Plant.Pump3", submit.SourceInstanceId);
// The originating script travels through from the buffered payload.
Assert.Equal("alarmScript", submit.SourceScript);
centralProbe.Reply(new NotificationSubmitAck(submit.NotificationId, Accepted: true, Error: null));
Assert.True(await deliverTask);
@@ -76,12 +87,15 @@ public class NotificationForwarderTests : TestKit
// A buffered payload carrying an empty-string ListName: the empty value must not
// be forwarded — the forwarder falls back to the S&F message Target instead.
var payload = JsonSerializer.Serialize(new
{
ListName = "",
Subject = "Pump alarm",
Message = "Pump 3 tripped"
});
var payload = JsonSerializer.Serialize(new NotificationSubmit(
NotificationId: "msg-empty-list",
ListName: "",
Subject: "Pump alarm",
Body: "Pump 3 tripped",
SourceSiteId: string.Empty,
SourceInstanceId: "Plant.Pump3",
SourceScript: null,
SiteEnqueuedAt: DateTimeOffset.UtcNow));
var msg = new StoreAndForwardMessage
{
Id = "msg-empty-list",