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

@@ -87,21 +87,28 @@ public sealed class NotificationForwarder
}
/// <summary>
/// Maps a buffered S&amp;F notification message onto a <see cref="NotificationSubmit"/>,
/// returning <c>false</c> if the payload is unreadable.
/// The <see cref="NotificationSubmit.NotificationId"/> is the central idempotency
/// key and must be stable across every retry of the same buffered message, so it is
/// derived from <see cref="StoreAndForwardMessage.Id"/> — a stable GUID assigned
/// once at enqueue time.
/// Maps a buffered S&amp;F notification message onto the <see cref="NotificationSubmit"/>
/// forwarded to central, returning <c>false</c> if the payload is unreadable.
///
/// The buffered payload IS a serialized <see cref="NotificationSubmit"/> written by
/// the site <c>Notify.Send</c> enqueue path (Task 19). Its
/// <see cref="NotificationSubmit.NotificationId"/> is the central idempotency key —
/// it was generated by the script, equals the buffered row's
/// <see cref="StoreAndForwardMessage.Id"/>, and is stable across every retry. The
/// forwarder forwards the payload as-is except that it re-stamps the fields it
/// authoritatively owns: <see cref="NotificationSubmit.SourceSiteId"/> (this site's
/// id) and <see cref="NotificationSubmit.SourceInstanceId"/> (the buffered row's
/// origin instance), and it falls the list name back to the S&amp;F
/// <see cref="StoreAndForwardMessage.Target"/> when the payload list name is blank.
/// </summary>
private bool TryBuildSubmit(StoreAndForwardMessage message, out NotificationSubmit submit)
{
submit = null!;
BufferedNotificationPayload? payload;
NotificationSubmit? payload;
try
{
payload = JsonSerializer.Deserialize<BufferedNotificationPayload>(message.PayloadJson);
payload = JsonSerializer.Deserialize<NotificationSubmit>(message.PayloadJson);
}
catch (JsonException)
{
@@ -113,30 +120,25 @@ public sealed class NotificationForwarder
return false;
}
submit = new NotificationSubmit(
NotificationId: message.Id,
// A null OR empty/blank ListName falls back to the S&F Target — matching the
// empty-string guard the former SMTP handler (NotificationDeliveryService)
// applied, so an empty list name is never forwarded to central.
ListName: string.IsNullOrEmpty(payload.ListName) ? message.Target : payload.ListName,
Subject: payload.Subject ?? string.Empty,
Body: payload.Message ?? string.Empty,
SourceSiteId: _sourceSiteId,
SourceInstanceId: message.OriginInstanceName,
// The buffered payload does not currently carry the originating script;
// Task 19 (the enqueue side) will add it. Null until then.
SourceScript: null,
SiteEnqueuedAt: message.CreatedAt);
submit = payload with
{
// The NotificationId is the script-generated idempotency key carried in the
// payload. Defend against a payload missing it by falling back to the
// buffered row id, which the enqueue path pins to the same value.
NotificationId = string.IsNullOrEmpty(payload.NotificationId)
? message.Id
: payload.NotificationId,
// A null OR empty/blank ListName falls back to the S&F Target — so an empty
// list name is never forwarded to central.
ListName = string.IsNullOrEmpty(payload.ListName) ? message.Target : payload.ListName,
// SourceSiteId/SourceInstanceId are authoritatively owned by the site: the
// forwarder knows the real site id, and the buffered row records the origin
// instance even after the instance is deleted.
SourceSiteId = _sourceSiteId,
SourceInstanceId = message.OriginInstanceName,
};
return true;
}
/// <summary>
/// Mirrors the payload shape written by the site notification enqueue path
/// (<c>{ ListName, Subject, Message }</c>). Kept private to this forwarder — Task 19
/// will reshape the enqueue payload, at which point this is updated alongside it.
/// </summary>
private sealed record BufferedNotificationPayload(
string? ListName, string? Subject, string? Message);
}
/// <summary>