feat(sms): stamp Notification.Type from list at ingest (S4)

This commit is contained in:
Joseph Doherty
2026-06-19 10:11:05 -04:00
parent 3827b98484
commit bffbb0c2da
2 changed files with 158 additions and 12 deletions
@@ -174,11 +174,12 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
private void HandleSubmit(NotificationSubmit msg)
{
var sender = Sender;
var notification = BuildNotification(msg);
// The success projection fires for both a fresh insert and an existing row;
// only a thrown repository error reaches the failure projection.
PersistAsync(notification).PipeTo(
// only a thrown repository error reaches the failure projection. The list
// lookup that stamps the channel Type happens inside PersistAsync so it can
// share the same DI scope as the insert.
PersistAsync(msg).PipeTo(
Self,
success: () => new InternalMessages.IngestPersisted(
msg.NotificationId, sender, Succeeded: true, Error: null),
@@ -187,16 +188,31 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Resolves a scoped <see cref="INotificationOutboxRepository"/> and inserts the
/// notification if a row with the same id does not already exist. The boolean result
/// of <c>InsertIfNotExistsAsync</c> is intentionally ignored: an existing row is an
/// idempotent re-submission and is acked just like a fresh insert so the site can
/// clear its forward buffer. Only a thrown error must surface to the caller.
/// Resolves the target notification list to stamp the delivery channel
/// <see cref="NotificationType"/> on a fresh row, then inserts the notification if a row
/// with the same id does not already exist. The boolean result of
/// <c>InsertIfNotExistsAsync</c> is intentionally ignored: an existing row is an
/// idempotent re-submission and is acked just like a fresh insert (and its persisted
/// Type is left untouched) so the site can clear its forward buffer. Only a thrown
/// error must surface to the caller.
/// </summary>
private async Task PersistAsync(Notification notification)
private async Task PersistAsync(NotificationSubmit msg)
{
using var scope = _serviceProvider.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
// The list's Type is the authoritative delivery channel. Resolve it here so a
// fresh row is stamped with the right channel (Email/Sms/...). GetService (not
// GetRequiredService) so the lookup is optional: a host without INotification
// Repository, or a missing list, falls back to Email — the notification then
// parks at delivery with "list not found", which is the unchanged behaviour.
var listRepository = scope.ServiceProvider.GetService<INotificationRepository>();
var list = listRepository is null
? null
: await listRepository.GetListByNameAsync(msg.ListName);
var type = list?.Type ?? NotificationType.Email;
var notification = BuildNotification(msg, type);
await repository.InsertIfNotExistsAsync(notification);
}
@@ -1139,12 +1155,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
return Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed) ? parsed : null;
}
private static Notification BuildNotification(NotificationSubmit msg)
private static Notification BuildNotification(NotificationSubmit msg, NotificationType type)
{
// All current notifications are email; NotificationType has only the Email member.
// The delivery channel is taken from the target list's Type (resolved by the
// caller); it defaults to Email when the list cannot be resolved.
return new Notification(
msg.NotificationId,
NotificationType.Email,
type,
msg.ListName,
msg.Subject,
msg.Body,