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:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -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);
}
@@ -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&lt;ISmtpClientWrapper&gt;</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;
}
}
@@ -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>