refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
|
||||
/// <summary>
|
||||
/// Classification of a single delivery attempt. Transient failures are eligible for
|
||||
/// retry; permanent failures are terminal and not retried.
|
||||
/// </summary>
|
||||
public enum DeliveryResult
|
||||
{
|
||||
/// <summary>The notification was delivered successfully.</summary>
|
||||
Success,
|
||||
|
||||
/// <summary>Delivery failed for a transient reason and may succeed on retry.</summary>
|
||||
TransientFailure,
|
||||
|
||||
/// <summary>Delivery failed for a permanent reason and must not be retried.</summary>
|
||||
PermanentFailure
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a delivery attempt produced by an <see cref="INotificationDeliveryAdapter"/>.
|
||||
/// </summary>
|
||||
/// <param name="Result">The classification of the attempt.</param>
|
||||
/// <param name="ResolvedTargets">
|
||||
/// The concrete delivery targets used, snapshotted for audit. Set only on success.
|
||||
/// </param>
|
||||
/// <param name="Error">A human-readable failure description. Set only on failure.</param>
|
||||
public record DeliveryOutcome(DeliveryResult Result, string? ResolvedTargets, string? Error)
|
||||
{
|
||||
/// <summary>Creates a successful outcome carrying the resolved delivery targets.</summary>
|
||||
/// <param name="resolvedTargets">The concrete delivery targets used, for audit.</param>
|
||||
public static DeliveryOutcome Success(string resolvedTargets) =>
|
||||
new(DeliveryResult.Success, resolvedTargets, null);
|
||||
|
||||
/// <summary>Creates a transient-failure outcome carrying an error description.</summary>
|
||||
/// <param name="error">Human-readable description of the transient failure.</param>
|
||||
public static DeliveryOutcome Transient(string error) =>
|
||||
new(DeliveryResult.TransientFailure, null, error);
|
||||
|
||||
/// <summary>Creates a permanent-failure outcome carrying an error description.</summary>
|
||||
/// <param name="error">Human-readable description of the permanent failure.</param>
|
||||
public static DeliveryOutcome Permanent(string error) =>
|
||||
new(DeliveryResult.PermanentFailure, null, error);
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
|
||||
/// <summary>
|
||||
/// Task 12: Email channel delivery adapter for the central notification outbox.
|
||||
///
|
||||
/// Reuses the <see cref="ZB.MOM.WW.ScadaBridge.NotificationService"/> SMTP primitives —
|
||||
/// <see cref="ISmtpClientWrapper"/>, <see cref="SmtpTlsModeParser"/>,
|
||||
/// <see cref="OAuth2TokenService"/> and the typed <see cref="SmtpPermanentException"/>.
|
||||
/// This adapter owns the full connect/auth/send/disconnect sequence and maps the
|
||||
/// outcome to the outbox's three-way <see cref="DeliveryOutcome"/> (Success / Permanent /
|
||||
/// Transient) — the canonical central-side email delivery path. NS-019: the prior
|
||||
/// site-shaped <c>NotificationDeliveryService</c> was deleted with sites no longer
|
||||
/// delivering notifications.
|
||||
/// </summary>
|
||||
public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdapter
|
||||
{
|
||||
private readonly INotificationRepository _repository;
|
||||
private readonly Func<ISmtpClientWrapper> _smtpClientFactory;
|
||||
private readonly OAuth2TokenService? _tokenService;
|
||||
private readonly ILogger<EmailNotificationDeliveryAdapter> _logger;
|
||||
private readonly NotificationOptions _options;
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="EmailNotificationDeliveryAdapter"/>.</summary>
|
||||
/// <param name="repository">Repository for resolving notification list recipients.</param>
|
||||
/// <param name="smtpClientFactory">Factory that creates a new SMTP client wrapper per delivery attempt.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="tokenService">Optional OAuth2 token service for Microsoft 365 Client Credentials auth.</param>
|
||||
/// <param name="options">Optional notification options providing documented fallback values.</param>
|
||||
public EmailNotificationDeliveryAdapter(
|
||||
INotificationRepository repository,
|
||||
Func<ISmtpClientWrapper> smtpClientFactory,
|
||||
ILogger<EmailNotificationDeliveryAdapter> logger,
|
||||
OAuth2TokenService? tokenService = null,
|
||||
IOptions<NotificationOptions>? options = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_smtpClientFactory = smtpClientFactory;
|
||||
_logger = logger;
|
||||
_tokenService = tokenService;
|
||||
// NotificationOptions supplies the documented fallback values used when a
|
||||
// deployed SmtpConfiguration row leaves a field unset (non-positive).
|
||||
_options = options?.Value ?? new NotificationOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeliveryOutcome> DeliverAsync(
|
||||
Notification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
var list = await _repository.GetListByNameAsync(notification.ListName, cancellationToken);
|
||||
if (list == null)
|
||||
{
|
||||
return DeliveryOutcome.Permanent(
|
||||
$"Notification list '{notification.ListName}' not found");
|
||||
}
|
||||
|
||||
var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken);
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
return DeliveryOutcome.Permanent(
|
||||
$"Notification list '{notification.ListName}' has no recipients");
|
||||
}
|
||||
|
||||
var smtpConfigs = await _repository.GetAllSmtpConfigurationsAsync(cancellationToken);
|
||||
var smtpConfig = smtpConfigs.FirstOrDefault();
|
||||
if (smtpConfig == null)
|
||||
{
|
||||
return DeliveryOutcome.Permanent("No SMTP configuration available");
|
||||
}
|
||||
|
||||
// An unknown TLS mode is a configuration error that retrying cannot fix —
|
||||
// surface it as a permanent failure (NS-005 SMTP TLS validation policy).
|
||||
SmtpTlsMode tlsMode;
|
||||
try
|
||||
{
|
||||
tlsMode = SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Email notification to list '{List}' has an invalid SMTP TLS mode: {Reason}",
|
||||
notification.ListName, ex.Message);
|
||||
return DeliveryOutcome.Permanent(ex.Message);
|
||||
}
|
||||
|
||||
// A malformed sender or recipient address cannot be fixed by retrying —
|
||||
// surface it as a permanent failure (mirrors NS-008).
|
||||
var addressError = EmailAddressValidator.ValidateAddresses(
|
||||
smtpConfig.FromAddress, recipients);
|
||||
if (addressError != null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Email notification to list '{List}' has invalid addresses: {Reason}",
|
||||
notification.ListName, addressError);
|
||||
return DeliveryOutcome.Permanent(addressError);
|
||||
}
|
||||
|
||||
var recipientAddresses = recipients.Select(r => r.EmailAddress).ToList();
|
||||
|
||||
try
|
||||
{
|
||||
await SendAsync(smtpConfig, tlsMode, recipientAddresses,
|
||||
notification.Subject, notification.Body, cancellationToken);
|
||||
|
||||
return DeliveryOutcome.Success(string.Join(", ", recipientAddresses));
|
||||
}
|
||||
catch (SmtpPermanentException ex)
|
||||
{
|
||||
// Permanent SMTP failure (5xx) — not retried.
|
||||
var detail = CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials);
|
||||
_logger.LogError(
|
||||
"Permanent SMTP failure delivering email to list '{List}': {Detail}",
|
||||
notification.ListName, detail);
|
||||
return DeliveryOutcome.Permanent(detail);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// A caller-requested cancellation propagates; it is neither a success
|
||||
// nor a delivery failure.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (SmtpErrorClassifier.IsTransient(ex, cancellationToken))
|
||||
{
|
||||
// Transient SMTP failure (4xx, socket/protocol/timeout) — eligible for retry.
|
||||
var detail = CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials);
|
||||
_logger.LogWarning(
|
||||
"Transient SMTP failure delivering email to list '{List}' ({ExceptionType}): {Detail}",
|
||||
notification.ListName, ex.GetType().Name, detail);
|
||||
return DeliveryOutcome.Transient(detail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// An unclassified failure — chiefly an OAuth2 token-fetch failure. The
|
||||
// outbox treats it as permanent: retrying a broken credential burns
|
||||
// token-endpoint calls. (Mirrors the NS-015 default-to-permanent stance.)
|
||||
var detail = CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials);
|
||||
_logger.LogError(
|
||||
"Unclassified failure delivering email to list '{List}' ({ExceptionType}): {Detail}",
|
||||
notification.ListName, ex.GetType().Name, detail);
|
||||
return DeliveryOutcome.Permanent($"Email delivery failed: {detail}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivers the plain-text BCC email via SMTP. A permanent failure surfaces as
|
||||
/// <see cref="SmtpPermanentException"/>; transient failures propagate for the
|
||||
/// caller's classifier; the connection is always torn down in the finally block.
|
||||
/// </summary>
|
||||
private async Task SendAsync(
|
||||
SmtpConfiguration config,
|
||||
SmtpTlsMode tlsMode,
|
||||
IReadOnlyList<string> bccAddresses,
|
||||
string subject,
|
||||
string body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Create exactly one client and dispose the one actually used (NS-004).
|
||||
var smtp = _smtpClientFactory();
|
||||
using var disposable = smtp as IDisposable;
|
||||
|
||||
try
|
||||
{
|
||||
var timeoutSeconds = config.ConnectionTimeoutSeconds > 0
|
||||
? config.ConnectionTimeoutSeconds
|
||||
: _options.ConnectionTimeoutSeconds;
|
||||
await smtp.ConnectAsync(
|
||||
config.Host, config.Port, tlsMode, timeoutSeconds, cancellationToken);
|
||||
|
||||
// Resolve credentials (OAuth2 token fetched/cached by the token service).
|
||||
var credentials = config.Credentials;
|
||||
if (config.AuthType.Equals("oauth2", StringComparison.OrdinalIgnoreCase)
|
||||
&& _tokenService != null && credentials != null)
|
||||
{
|
||||
credentials = await _tokenService.GetTokenAsync(credentials, cancellationToken);
|
||||
}
|
||||
|
||||
// NO-001/NS-021: OAuth2 XOAUTH2 requires the user identity (FromAddress)
|
||||
// to be sent alongside the access token; an empty user is rejected by M365.
|
||||
await smtp.AuthenticateAsync(
|
||||
config.AuthType,
|
||||
credentials,
|
||||
oauth2UserName: config.FromAddress,
|
||||
cancellationToken: cancellationToken);
|
||||
await smtp.SendAsync(config.FromAddress, bccAddresses, subject, body, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// A deliberate cancellation must propagate, not be misclassified as transient.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (SmtpErrorClassifier.Classify(ex, cancellationToken) == SmtpErrorClass.Permanent
|
||||
&& ex is not SmtpPermanentException)
|
||||
{
|
||||
// Permanent SMTP failure (5xx) — surface a typed permanent exception.
|
||||
throw new SmtpPermanentException(ex.Message, ex);
|
||||
}
|
||||
// Transient and SmtpPermanentException propagate unchanged for DeliverAsync's
|
||||
// catch filters to classify.
|
||||
finally
|
||||
{
|
||||
// Always tear the connection down, regardless of outcome (NS-010).
|
||||
// Disconnect is best-effort: a disconnect failure must not mask the
|
||||
// original delivery exception.
|
||||
try
|
||||
{
|
||||
await smtp.DisconnectAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception disconnectEx)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Ignoring SMTP disconnect failure during cleanup: {Reason}", disconnectEx.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
|
||||
/// <summary>
|
||||
/// Channel-specific delivery strategy for outbox notifications. Each adapter handles
|
||||
/// a single <see cref="NotificationType"/>; the outbox dispatcher selects the adapter
|
||||
/// matching a notification's type.
|
||||
/// </summary>
|
||||
public interface INotificationDeliveryAdapter
|
||||
{
|
||||
/// <summary>The notification channel this adapter delivers.</summary>
|
||||
NotificationType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts delivery of the given notification and reports the classified outcome.
|
||||
/// </summary>
|
||||
/// <param name="notification">The notification to deliver.</param>
|
||||
/// <param name="cancellationToken">Token used to cancel the delivery attempt.</param>
|
||||
/// <returns>The outcome of the delivery attempt.</returns>
|
||||
Task<DeliveryOutcome> DeliverAsync(Notification notification, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Akka.Actor;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Actor-internal message types for the <see cref="NotificationOutboxActor"/>. These are
|
||||
/// never sent across the network — they bridge the actor's async repository/delivery work
|
||||
/// back onto the actor's own mailbox so handlers run single-threaded on the actor.
|
||||
/// </summary>
|
||||
internal static class InternalMessages
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of an asynchronous ingest persistence attempt, piped back to the actor.
|
||||
/// Carries the original <paramref name="Sender"/> so the actor can ack the site that
|
||||
/// submitted the notification once the insert completes.
|
||||
/// </summary>
|
||||
/// <param name="NotificationId">Id of the notification that was submitted.</param>
|
||||
/// <param name="Sender">Original submitter to receive the ack.</param>
|
||||
/// <param name="Succeeded">
|
||||
/// True if persistence completed without error — covers both a fresh insert and an
|
||||
/// already-existing row (idempotent re-submission). False only when the repository threw.
|
||||
/// </param>
|
||||
/// <param name="Error">Failure detail when <paramref name="Succeeded"/> is false; otherwise null.</param>
|
||||
internal sealed record IngestPersisted(
|
||||
string NotificationId,
|
||||
IActorRef Sender,
|
||||
bool Succeeded,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Periodic tick that triggers a dispatch sweep. Started as a periodic timer in
|
||||
/// <c>PreStart</c> at the configured <c>DispatchInterval</c>. A singleton instance is
|
||||
/// reused so the timer carries no per-tick state.
|
||||
/// </summary>
|
||||
internal sealed class DispatchTick
|
||||
{
|
||||
/// <summary>The shared singleton tick instance scheduled by the dispatch timer.</summary>
|
||||
internal static readonly DispatchTick Instance = new();
|
||||
|
||||
private DispatchTick() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completion signal for an asynchronous dispatch sweep, piped back to the actor so the
|
||||
/// in-flight guard is cleared on the actor thread. Sent on both success and failure of
|
||||
/// the sweep — the actor only needs to know the sweep has finished.
|
||||
/// </summary>
|
||||
internal sealed class DispatchComplete
|
||||
{
|
||||
/// <summary>The shared singleton completion instance.</summary>
|
||||
internal static readonly DispatchComplete Instance = new();
|
||||
|
||||
private DispatchComplete() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodic tick that triggers a purge sweep of terminal notification rows. Started as a
|
||||
/// periodic timer in <c>PreStart</c> at the configured <c>PurgeInterval</c>. A singleton
|
||||
/// instance is reused so the timer carries no per-tick state.
|
||||
/// </summary>
|
||||
internal sealed class PurgeTick
|
||||
{
|
||||
/// <summary>The shared singleton tick instance scheduled by the purge timer.</summary>
|
||||
internal static readonly PurgeTick Instance = new();
|
||||
|
||||
private PurgeTick() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completion signal for an asynchronous purge sweep, piped back to the actor so the
|
||||
/// sweep's outcome (logged in the pipe projection) is observed on the actor thread.
|
||||
/// Sent on both success and failure of the sweep.
|
||||
/// </summary>
|
||||
internal sealed class PurgeComplete
|
||||
{
|
||||
/// <summary>The shared singleton completion instance.</summary>
|
||||
internal static readonly PurgeComplete Instance = new();
|
||||
|
||||
private PurgeComplete() { }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Notification Outbox component: dispatch cadence,
|
||||
/// batch sizing, stuck-message detection, terminal retention, and KPI windowing.
|
||||
/// </summary>
|
||||
public class NotificationOutboxOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Interval between dispatch sweeps that pick up pending notifications for delivery.
|
||||
/// Default: 10 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan DispatchInterval { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of notifications claimed for delivery in a single dispatch sweep.
|
||||
/// Caps per-sweep batch size to bound memory and per-iteration latency.
|
||||
/// Default: 100.
|
||||
/// </summary>
|
||||
public int DispatchBatchSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Age past which a still-<c>Pending</c>/<c>Retrying</c> notification is counted as
|
||||
/// "stuck" on the KPI tile and the per-row badge in the Central UI.
|
||||
/// Display-only: rows older than this threshold are flagged in KPIs/UI; there is no
|
||||
/// automatic re-claim, requeue, or escalation — the dispatcher behaviour is unaffected.
|
||||
/// Default: 10 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan StuckAgeThreshold { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for notifications in a terminal state (<c>Delivered</c>,
|
||||
/// <c>Parked</c>, <c>Discarded</c>) before they are purged from the Notifications table.
|
||||
/// Default: 365 days.
|
||||
/// </summary>
|
||||
public TimeSpan TerminalRetention { get; set; } = TimeSpan.FromDays(365);
|
||||
|
||||
/// <summary>
|
||||
/// Interval between background purge sweeps of terminal notifications older than
|
||||
/// <see cref="TerminalRetention"/>.
|
||||
/// Default: 1 day.
|
||||
/// </summary>
|
||||
public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>
|
||||
/// Trailing window used to compute the "delivered (last interval)" throughput KPI
|
||||
/// surfaced on the Health dashboard and the Notification Outbox page.
|
||||
/// Default: 1 minute.
|
||||
/// </summary>
|
||||
public TimeSpan DeliveredKpiWindow { get; set; } = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration for the Notification Outbox component: binds
|
||||
/// <see cref="NotificationOutboxOptions"/> and registers the channel delivery adapters.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Configuration section bound to <see cref="NotificationOutboxOptions"/>.</summary>
|
||||
public const string OptionsSection = "ScadaBridge:NotificationOutbox";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Notification Outbox services: the <see cref="NotificationOutboxOptions"/>
|
||||
/// binding and the channel delivery adapters.
|
||||
///
|
||||
/// This extension covers only the outbox-specific registrations. The
|
||||
/// <see cref="EmailNotificationDeliveryAdapter"/> reuses the
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.NotificationService"/> SMTP machinery —
|
||||
/// <c>Func<ISmtpClientWrapper></c>, <c>OAuth2TokenService</c> and
|
||||
/// <c>NotificationOptions</c> — so the caller (the Host on the central node) must also
|
||||
/// call <c>AddNotificationService()</c>. Re-registering those services here would
|
||||
/// duplicate them; relying on <c>AddNotificationService</c> keeps a single source of truth.
|
||||
///
|
||||
/// <see cref="EmailNotificationDeliveryAdapter"/> is registered <em>scoped</em> because it
|
||||
/// takes a scoped <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.INotificationRepository"/>
|
||||
/// directly. The <see cref="NotificationOutboxActor"/> resolves the adapters from a fresh
|
||||
/// scope per dispatch sweep rather than holding them, so no scoped adapter is captured by
|
||||
/// the singleton actor.
|
||||
/// </summary>
|
||||
/// <param name="services">The DI service collection to register notification outbox services into.</param>
|
||||
public static IServiceCollection AddNotificationOutbox(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<NotificationOutboxOptions>()
|
||||
.BindConfiguration(OptionsSection);
|
||||
|
||||
// Scoped: the adapter holds a scoped INotificationRepository. Registered both under
|
||||
// the interface (so the dispatch sweep can enumerate every channel adapter) and as
|
||||
// the concrete type (so callers and tests can resolve it directly).
|
||||
services.AddScoped<EmailNotificationDeliveryAdapter>();
|
||||
services.AddScoped<INotificationDeliveryAdapter>(
|
||||
sp => sp.GetRequiredService<EmailNotificationDeliveryAdapter>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka" />
|
||||
<PackageReference Include="Akka.Cluster.Tools" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<!-- Email delivery adapter reuses the NotificationService SMTP machinery
|
||||
(ISmtpClientWrapper, SmtpPermanentException, SmtpTlsModeParser,
|
||||
OAuth2TokenService). -->
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests" />
|
||||
<!--
|
||||
Audit Log #23 (M4 Bundle E — Task E2): the cross-project
|
||||
NotifyDispatcherAuditTrailTests need to drive the dispatcher loop
|
||||
deterministically via the internal InternalMessages.DispatchTick.Instance
|
||||
sentinel (same pattern the existing NotificationOutbox.Tests use).
|
||||
-->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.AuditLog.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user