up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,59 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class EmailChannelHealthProvider : INotifyChannelHealthProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var builder = EmailMetadataBuilder.CreateBuilder(context)
|
||||
.Add("email.channel.enabled", context.Channel.Enabled ? "true" : "false")
|
||||
.Add("email.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
|
||||
|
||||
var metadata = builder.Build();
|
||||
var status = ResolveStatus(context.Channel);
|
||||
var message = status switch
|
||||
{
|
||||
ChannelHealthStatus.Healthy => "Email channel configuration validated.",
|
||||
ChannelHealthStatus.Degraded => "Email channel is disabled; enable it to resume deliveries.",
|
||||
ChannelHealthStatus.Unhealthy => "Email channel target/configuration incomplete.",
|
||||
_ => "Email channel diagnostics completed."
|
||||
};
|
||||
|
||||
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
|
||||
}
|
||||
|
||||
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
|
||||
{
|
||||
if (!HasConfiguredTarget(channel))
|
||||
{
|
||||
return ChannelHealthStatus.Unhealthy;
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthStatus.Degraded;
|
||||
}
|
||||
|
||||
return ChannelHealthStatus.Healthy;
|
||||
}
|
||||
|
||||
private static bool HasConfiguredTarget(NotifyChannel channel)
|
||||
=> !string.IsNullOrWhiteSpace(channel.Config.Target) ||
|
||||
(channel.Config.Properties is not null &&
|
||||
channel.Config.Properties.TryGetValue("fromAddress", out var from) &&
|
||||
!string.IsNullOrWhiteSpace(from));
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class EmailChannelHealthProvider : INotifyChannelHealthProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var builder = EmailMetadataBuilder.CreateBuilder(context)
|
||||
.Add("email.channel.enabled", context.Channel.Enabled ? "true" : "false")
|
||||
.Add("email.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
|
||||
|
||||
var metadata = builder.Build();
|
||||
var status = ResolveStatus(context.Channel);
|
||||
var message = status switch
|
||||
{
|
||||
ChannelHealthStatus.Healthy => "Email channel configuration validated.",
|
||||
ChannelHealthStatus.Degraded => "Email channel is disabled; enable it to resume deliveries.",
|
||||
ChannelHealthStatus.Unhealthy => "Email channel target/configuration incomplete.",
|
||||
_ => "Email channel diagnostics completed."
|
||||
};
|
||||
|
||||
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
|
||||
}
|
||||
|
||||
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
|
||||
{
|
||||
if (!HasConfiguredTarget(channel))
|
||||
{
|
||||
return ChannelHealthStatus.Unhealthy;
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthStatus.Degraded;
|
||||
}
|
||||
|
||||
return ChannelHealthStatus.Healthy;
|
||||
}
|
||||
|
||||
private static bool HasConfiguredTarget(NotifyChannel channel)
|
||||
=> !string.IsNullOrWhiteSpace(channel.Config.Target) ||
|
||||
(channel.Config.Properties is not null &&
|
||||
channel.Config.Properties.TryGetValue("fromAddress", out var from) &&
|
||||
!string.IsNullOrWhiteSpace(from));
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class EmailChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var subject = context.Request.Title ?? "Stella Ops Notify Preview";
|
||||
var summary = context.Request.Summary ?? $"Preview generated at {context.Timestamp:O}.";
|
||||
var htmlBody = !string.IsNullOrWhiteSpace(context.Request.Body)
|
||||
? context.Request.Body!
|
||||
: $"<p>{summary}</p><p><small>Trace: {context.TraceId}</small></p>";
|
||||
var textBody = context.Request.TextBody ?? $"{summary}{Environment.NewLine}Trace: {context.TraceId}";
|
||||
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Email,
|
||||
NotifyDeliveryFormat.Email,
|
||||
context.Target,
|
||||
subject,
|
||||
htmlBody,
|
||||
summary,
|
||||
textBody,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(htmlBody),
|
||||
context.Request.Attachments);
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class EmailChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var subject = context.Request.Title ?? "Stella Ops Notify Preview";
|
||||
var summary = context.Request.Summary ?? $"Preview generated at {context.Timestamp:O}.";
|
||||
var htmlBody = !string.IsNullOrWhiteSpace(context.Request.Body)
|
||||
? context.Request.Body!
|
||||
: $"<p>{summary}</p><p><small>Trace: {context.TraceId}</small></p>";
|
||||
var textBody = context.Request.TextBody ?? $"{summary}{Environment.NewLine}Trace: {context.TraceId}";
|
||||
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Email,
|
||||
NotifyDeliveryFormat.Email,
|
||||
context.Target,
|
||||
subject,
|
||||
htmlBody,
|
||||
summary,
|
||||
textBody,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(htmlBody),
|
||||
context.Request.Attachments);
|
||||
|
||||
var metadata = EmailMetadataBuilder.Build(context);
|
||||
|
||||
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Email previews and health diagnostics with redacted secrets.
|
||||
/// </summary>
|
||||
internal static class EmailMetadataBuilder
|
||||
{
|
||||
private const int SecretHashLengthBytes = 8;
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("email.target", target)
|
||||
.AddTimestamp("email.preview.generatedAt", timestamp)
|
||||
.AddSecretRefHash("email.secretRef.hash", secretRef, SecretHashLengthBytes)
|
||||
.AddConfigProperties("email.config.", properties);
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Email previews and health diagnostics with redacted secrets.
|
||||
/// </summary>
|
||||
internal static class EmailMetadataBuilder
|
||||
{
|
||||
private const int SecretHashLengthBytes = 8;
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("email.target", target)
|
||||
.AddTimestamp("email.preview.generatedAt", timestamp)
|
||||
.AddSecretRefHash("email.secretRef.hash", secretRef, SecretHashLengthBytes)
|
||||
.AddConfigProperties("email.config.", properties);
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Common hashing helpers for Notify connector metadata.
|
||||
/// </summary>
|
||||
public static class ConnectorHashing
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a lowercase hex SHA-256 hash and truncates it to the requested number of bytes.
|
||||
/// </summary>
|
||||
public static string ComputeSha256Hash(string value, int lengthBytes = 8)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value must not be null or whitespace.", nameof(value));
|
||||
}
|
||||
|
||||
if (lengthBytes <= 0 || lengthBytes > 32)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(lengthBytes), "Length must be between 1 and 32 bytes.");
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(value.Trim());
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash.AsSpan(0, lengthBytes)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Common hashing helpers for Notify connector metadata.
|
||||
/// </summary>
|
||||
public static class ConnectorHashing
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a lowercase hex SHA-256 hash and truncates it to the requested number of bytes.
|
||||
/// </summary>
|
||||
public static string ComputeSha256Hash(string value, int lengthBytes = 8)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value must not be null or whitespace.", nameof(value));
|
||||
}
|
||||
|
||||
if (lengthBytes <= 0 || lengthBytes > 32)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(lengthBytes), "Length must be between 1 and 32 bytes.");
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(value.Trim());
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash.AsSpan(0, lengthBytes)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,147 +1,147 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Utility for constructing connector metadata payloads with consistent redaction rules.
|
||||
/// </summary>
|
||||
public sealed class ConnectorMetadataBuilder
|
||||
{
|
||||
private readonly Dictionary<string, string> _metadata;
|
||||
|
||||
public ConnectorMetadataBuilder(StringComparer? comparer = null)
|
||||
{
|
||||
_metadata = new Dictionary<string, string>(comparer ?? StringComparer.Ordinal);
|
||||
SensitiveFragments = new HashSet<string>(ConnectorValueRedactor.DefaultSensitiveKeyFragments, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection of key fragments treated as sensitive when redacting values.
|
||||
/// </summary>
|
||||
public ISet<string> SensitiveFragments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds or replaces a metadata entry when the value is non-empty.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder Add(string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
_metadata[key.Trim()] = value.Trim();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the target value metadata. The value is trimmed but not redacted.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddTarget(string key, string target)
|
||||
=> Add(key, target);
|
||||
|
||||
/// <summary>
|
||||
/// Adds ISO-8601 timestamp metadata.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddTimestamp(string key, DateTimeOffset timestamp)
|
||||
=> Add(key, timestamp.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
/// <summary>
|
||||
/// Adds a hash of the secret reference when present.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddSecretRefHash(string key, string? secretRef, int lengthBytes = 8)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(secretRef))
|
||||
{
|
||||
Add(key, ConnectorHashing.ComputeSha256Hash(secretRef, lengthBytes));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds configuration target metadata only when the stored configuration differs from the resolved target.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddConfigTarget(string key, string? configuredTarget, string resolvedTarget)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredTarget) &&
|
||||
!string.Equals(configuredTarget, resolvedTarget, StringComparison.Ordinal))
|
||||
{
|
||||
Add(key, configuredTarget);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds configuration endpoint metadata when present.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddConfigEndpoint(string key, string? endpoint)
|
||||
=> Add(key, endpoint);
|
||||
|
||||
/// <summary>
|
||||
/// Adds key/value metadata pairs from the provided dictionary, applying redaction to sensitive entries.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddConfigProperties(
|
||||
string prefix,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
Func<string, string, string>? valueSelector = null)
|
||||
{
|
||||
if (properties is null || properties.Count == 0)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
foreach (var pair in properties)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = prefix + pair.Key.Trim();
|
||||
var value = valueSelector is null
|
||||
? Redact(pair.Key, pair.Value)
|
||||
: valueSelector(pair.Key, pair.Value);
|
||||
|
||||
Add(key, value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges additional metadata entries into the builder.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddRange(IEnumerable<KeyValuePair<string, string>> entries)
|
||||
{
|
||||
foreach (var (key, value) in entries)
|
||||
{
|
||||
Add(key, value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the redacted representation for the supplied key/value pair.
|
||||
/// </summary>
|
||||
public string Redact(string key, string value)
|
||||
{
|
||||
if (ConnectorValueRedactor.IsSensitiveKey(key, SensitiveFragments))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactSecret(value);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an immutable view of the accumulated metadata.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Build()
|
||||
=> new ReadOnlyDictionary<string, string>(_metadata);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Utility for constructing connector metadata payloads with consistent redaction rules.
|
||||
/// </summary>
|
||||
public sealed class ConnectorMetadataBuilder
|
||||
{
|
||||
private readonly Dictionary<string, string> _metadata;
|
||||
|
||||
public ConnectorMetadataBuilder(StringComparer? comparer = null)
|
||||
{
|
||||
_metadata = new Dictionary<string, string>(comparer ?? StringComparer.Ordinal);
|
||||
SensitiveFragments = new HashSet<string>(ConnectorValueRedactor.DefaultSensitiveKeyFragments, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection of key fragments treated as sensitive when redacting values.
|
||||
/// </summary>
|
||||
public ISet<string> SensitiveFragments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds or replaces a metadata entry when the value is non-empty.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder Add(string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
_metadata[key.Trim()] = value.Trim();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the target value metadata. The value is trimmed but not redacted.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddTarget(string key, string target)
|
||||
=> Add(key, target);
|
||||
|
||||
/// <summary>
|
||||
/// Adds ISO-8601 timestamp metadata.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddTimestamp(string key, DateTimeOffset timestamp)
|
||||
=> Add(key, timestamp.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
/// <summary>
|
||||
/// Adds a hash of the secret reference when present.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddSecretRefHash(string key, string? secretRef, int lengthBytes = 8)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(secretRef))
|
||||
{
|
||||
Add(key, ConnectorHashing.ComputeSha256Hash(secretRef, lengthBytes));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds configuration target metadata only when the stored configuration differs from the resolved target.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddConfigTarget(string key, string? configuredTarget, string resolvedTarget)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredTarget) &&
|
||||
!string.Equals(configuredTarget, resolvedTarget, StringComparison.Ordinal))
|
||||
{
|
||||
Add(key, configuredTarget);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds configuration endpoint metadata when present.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddConfigEndpoint(string key, string? endpoint)
|
||||
=> Add(key, endpoint);
|
||||
|
||||
/// <summary>
|
||||
/// Adds key/value metadata pairs from the provided dictionary, applying redaction to sensitive entries.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddConfigProperties(
|
||||
string prefix,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
Func<string, string, string>? valueSelector = null)
|
||||
{
|
||||
if (properties is null || properties.Count == 0)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
foreach (var pair in properties)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = prefix + pair.Key.Trim();
|
||||
var value = valueSelector is null
|
||||
? Redact(pair.Key, pair.Value)
|
||||
: valueSelector(pair.Key, pair.Value);
|
||||
|
||||
Add(key, value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges additional metadata entries into the builder.
|
||||
/// </summary>
|
||||
public ConnectorMetadataBuilder AddRange(IEnumerable<KeyValuePair<string, string>> entries)
|
||||
{
|
||||
foreach (var (key, value) in entries)
|
||||
{
|
||||
Add(key, value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the redacted representation for the supplied key/value pair.
|
||||
/// </summary>
|
||||
public string Redact(string key, string value)
|
||||
{
|
||||
if (ConnectorValueRedactor.IsSensitiveKey(key, SensitiveFragments))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactSecret(value);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an immutable view of the accumulated metadata.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Build()
|
||||
=> new ReadOnlyDictionary<string, string>(_metadata);
|
||||
}
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for redacting sensitive connector metadata.
|
||||
/// </summary>
|
||||
public static class ConnectorValueRedactor
|
||||
{
|
||||
private static readonly string[] DefaultSensitiveFragments =
|
||||
{
|
||||
"token",
|
||||
"secret",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"password",
|
||||
"key",
|
||||
"credential"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default set of sensitive key fragments.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> DefaultSensitiveKeyFragments => DefaultSensitiveFragments;
|
||||
|
||||
/// <summary>
|
||||
/// Uses a constant mask for sensitive values.
|
||||
/// </summary>
|
||||
public static string RedactSecret(string value) => "***";
|
||||
|
||||
/// <summary>
|
||||
/// Redacts the middle portion of a token while keeping stable prefix/suffix bytes.
|
||||
/// </summary>
|
||||
public static string RedactToken(string value, int prefixLength = 6, int suffixLength = 4)
|
||||
{
|
||||
var trimmed = value?.Trim() ?? string.Empty;
|
||||
if (trimmed.Length <= prefixLength + suffixLength)
|
||||
{
|
||||
return RedactSecret(trimmed);
|
||||
}
|
||||
|
||||
var prefix = trimmed[..prefixLength];
|
||||
var suffix = trimmed[^suffixLength..];
|
||||
return string.Concat(prefix, "***", suffix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the provided key appears to represent sensitive data.
|
||||
/// </summary>
|
||||
public static bool IsSensitiveKey(string key, IEnumerable<string>? fragments = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
fragments ??= DefaultSensitiveFragments;
|
||||
var span = key.AsSpan();
|
||||
foreach (var fragment in fragments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fragment))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (span.IndexOf(fragment.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for redacting sensitive connector metadata.
|
||||
/// </summary>
|
||||
public static class ConnectorValueRedactor
|
||||
{
|
||||
private static readonly string[] DefaultSensitiveFragments =
|
||||
{
|
||||
"token",
|
||||
"secret",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"password",
|
||||
"key",
|
||||
"credential"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default set of sensitive key fragments.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> DefaultSensitiveKeyFragments => DefaultSensitiveFragments;
|
||||
|
||||
/// <summary>
|
||||
/// Uses a constant mask for sensitive values.
|
||||
/// </summary>
|
||||
public static string RedactSecret(string value) => "***";
|
||||
|
||||
/// <summary>
|
||||
/// Redacts the middle portion of a token while keeping stable prefix/suffix bytes.
|
||||
/// </summary>
|
||||
public static string RedactToken(string value, int prefixLength = 6, int suffixLength = 4)
|
||||
{
|
||||
var trimmed = value?.Trim() ?? string.Empty;
|
||||
if (trimmed.Length <= prefixLength + suffixLength)
|
||||
{
|
||||
return RedactSecret(trimmed);
|
||||
}
|
||||
|
||||
var prefix = trimmed[..prefixLength];
|
||||
var suffix = trimmed[^suffixLength..];
|
||||
return string.Concat(prefix, "***", suffix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the provided key appears to represent sensitive data.
|
||||
/// </summary>
|
||||
public static bool IsSensitiveKey(string key, IEnumerable<string>? fragments = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
fragments ??= DefaultSensitiveFragments;
|
||||
var span = key.AsSpan();
|
||||
foreach (var fragment in fragments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fragment))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (span.IndexOf(fragment.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class SlackChannelHealthProvider : INotifyChannelHealthProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var builder = SlackMetadataBuilder.CreateBuilder(context)
|
||||
.Add("slack.channel.enabled", context.Channel.Enabled ? "true" : "false")
|
||||
.Add("slack.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
|
||||
|
||||
var metadata = builder.Build();
|
||||
var status = ResolveStatus(context.Channel);
|
||||
var message = status switch
|
||||
{
|
||||
ChannelHealthStatus.Healthy => "Slack channel configuration validated.",
|
||||
ChannelHealthStatus.Degraded => "Slack channel is disabled; enable it to resume deliveries.",
|
||||
ChannelHealthStatus.Unhealthy => "Slack channel is missing a configured destination (target).",
|
||||
_ => "Slack channel diagnostics completed."
|
||||
};
|
||||
|
||||
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
|
||||
}
|
||||
|
||||
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
|
||||
{
|
||||
if (!HasConfiguredTarget(channel))
|
||||
{
|
||||
return ChannelHealthStatus.Unhealthy;
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthStatus.Degraded;
|
||||
}
|
||||
|
||||
return ChannelHealthStatus.Healthy;
|
||||
}
|
||||
|
||||
private static bool HasConfiguredTarget(NotifyChannel channel)
|
||||
=> !string.IsNullOrWhiteSpace(channel.Config.Target);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class SlackChannelHealthProvider : INotifyChannelHealthProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var builder = SlackMetadataBuilder.CreateBuilder(context)
|
||||
.Add("slack.channel.enabled", context.Channel.Enabled ? "true" : "false")
|
||||
.Add("slack.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
|
||||
|
||||
var metadata = builder.Build();
|
||||
var status = ResolveStatus(context.Channel);
|
||||
var message = status switch
|
||||
{
|
||||
ChannelHealthStatus.Healthy => "Slack channel configuration validated.",
|
||||
ChannelHealthStatus.Degraded => "Slack channel is disabled; enable it to resume deliveries.",
|
||||
ChannelHealthStatus.Unhealthy => "Slack channel is missing a configured destination (target).",
|
||||
_ => "Slack channel diagnostics completed."
|
||||
};
|
||||
|
||||
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
|
||||
}
|
||||
|
||||
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
|
||||
{
|
||||
if (!HasConfiguredTarget(channel))
|
||||
{
|
||||
return ChannelHealthStatus.Unhealthy;
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthStatus.Degraded;
|
||||
}
|
||||
|
||||
return ChannelHealthStatus.Healthy;
|
||||
}
|
||||
|
||||
private static bool HasConfiguredTarget(NotifyChannel channel)
|
||||
=> !string.IsNullOrWhiteSpace(channel.Config.Target);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class SlackChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class SlackChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private static readonly string DefaultTitle = "Stella Ops Notify Preview";
|
||||
@@ -54,9 +54,9 @@ public sealed class SlackChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new { type = "mrkdwn", text = $"*{title}*\n{bodyText}" }
|
||||
},
|
||||
type = "section",
|
||||
text = new { type = "mrkdwn", text = $"*{title}*\n{bodyText}" }
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "context",
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Slack previews and health diagnostics while redacting sensitive material.
|
||||
/// </summary>
|
||||
internal static class SlackMetadataBuilder
|
||||
{
|
||||
private static readonly string[] RequiredScopes = { "chat:write", "chat:write.public" };
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("slack.channel", target)
|
||||
.Add("slack.scopes.required", string.Join(',', RequiredScopes))
|
||||
.AddTimestamp("slack.preview.generatedAt", timestamp)
|
||||
.AddSecretRefHash("slack.secretRef.hash", secretRef)
|
||||
.AddConfigTarget("slack.config.target", channel.Config.Target, target)
|
||||
.AddConfigProperties("slack.config.", properties, (key, value) => RedactSlackValue(builder, key, value));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static string RedactSlackValue(ConnectorMetadataBuilder builder, string key, string value)
|
||||
{
|
||||
if (LooksLikeSlackToken(value))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactToken(value);
|
||||
}
|
||||
|
||||
return builder.Redact(key, value);
|
||||
}
|
||||
|
||||
private static bool LooksLikeSlackToken(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length < 6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return trimmed.StartsWith("xox", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Slack previews and health diagnostics while redacting sensitive material.
|
||||
/// </summary>
|
||||
internal static class SlackMetadataBuilder
|
||||
{
|
||||
private static readonly string[] RequiredScopes = { "chat:write", "chat:write.public" };
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("slack.channel", target)
|
||||
.Add("slack.scopes.required", string.Join(',', RequiredScopes))
|
||||
.AddTimestamp("slack.preview.generatedAt", timestamp)
|
||||
.AddSecretRefHash("slack.secretRef.hash", secretRef)
|
||||
.AddConfigTarget("slack.config.target", channel.Config.Target, target)
|
||||
.AddConfigProperties("slack.config.", properties, (key, value) => RedactSlackValue(builder, key, value));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static string RedactSlackValue(ConnectorMetadataBuilder builder, string key, string value)
|
||||
{
|
||||
if (LooksLikeSlackToken(value))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactToken(value);
|
||||
}
|
||||
|
||||
return builder.Redact(key, value);
|
||||
}
|
||||
|
||||
private static bool LooksLikeSlackToken(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length < 6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return trimmed.StartsWith("xox", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Teams;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class TeamsChannelHealthProvider : INotifyChannelHealthProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Teams;
|
||||
|
||||
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var builder = TeamsMetadataBuilder.CreateBuilder(context)
|
||||
.Add("teams.channel.enabled", context.Channel.Enabled ? "true" : "false")
|
||||
.Add("teams.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
|
||||
|
||||
var metadata = builder.Build();
|
||||
var status = ResolveStatus(context.Channel);
|
||||
var message = status switch
|
||||
{
|
||||
ChannelHealthStatus.Healthy => "Teams channel configuration validated.",
|
||||
ChannelHealthStatus.Degraded => "Teams channel is disabled; enable it to resume deliveries.",
|
||||
ChannelHealthStatus.Unhealthy => "Teams channel is missing a target/endpoint configuration.",
|
||||
_ => "Teams channel diagnostics completed."
|
||||
};
|
||||
|
||||
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
|
||||
}
|
||||
|
||||
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
|
||||
{
|
||||
if (!HasConfiguredTarget(channel))
|
||||
{
|
||||
return ChannelHealthStatus.Unhealthy;
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthStatus.Degraded;
|
||||
}
|
||||
|
||||
return ChannelHealthStatus.Healthy;
|
||||
}
|
||||
|
||||
private static bool HasConfiguredTarget(NotifyChannel channel)
|
||||
=> !string.IsNullOrWhiteSpace(channel.Config.Endpoint) ||
|
||||
!string.IsNullOrWhiteSpace(channel.Config.Target);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Teams;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class TeamsChannelHealthProvider : INotifyChannelHealthProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Teams;
|
||||
|
||||
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var builder = TeamsMetadataBuilder.CreateBuilder(context)
|
||||
.Add("teams.channel.enabled", context.Channel.Enabled ? "true" : "false")
|
||||
.Add("teams.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
|
||||
|
||||
var metadata = builder.Build();
|
||||
var status = ResolveStatus(context.Channel);
|
||||
var message = status switch
|
||||
{
|
||||
ChannelHealthStatus.Healthy => "Teams channel configuration validated.",
|
||||
ChannelHealthStatus.Degraded => "Teams channel is disabled; enable it to resume deliveries.",
|
||||
ChannelHealthStatus.Unhealthy => "Teams channel is missing a target/endpoint configuration.",
|
||||
_ => "Teams channel diagnostics completed."
|
||||
};
|
||||
|
||||
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
|
||||
}
|
||||
|
||||
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
|
||||
{
|
||||
if (!HasConfiguredTarget(channel))
|
||||
{
|
||||
return ChannelHealthStatus.Unhealthy;
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthStatus.Degraded;
|
||||
}
|
||||
|
||||
return ChannelHealthStatus.Healthy;
|
||||
}
|
||||
|
||||
private static bool HasConfiguredTarget(NotifyChannel channel)
|
||||
=> !string.IsNullOrWhiteSpace(channel.Config.Endpoint) ||
|
||||
!string.IsNullOrWhiteSpace(channel.Config.Target);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -49,13 +49,13 @@ public sealed class TeamsChannelTestProvider : INotifyChannelTestProvider
|
||||
new
|
||||
{
|
||||
contentType = "application/vnd.microsoft.card.adaptive",
|
||||
content = card
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
content = card
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Teams,
|
||||
NotifyDeliveryFormat.Teams,
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Teams previews and health diagnostics while redacting sensitive material.
|
||||
/// </summary>
|
||||
internal static class TeamsMetadataBuilder
|
||||
{
|
||||
internal const string CardVersion = "1.5";
|
||||
|
||||
private const int SecretHashLengthBytes = 8;
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context, string fallbackText)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
fallbackText: fallbackText,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef,
|
||||
endpoint: context.Channel.Config.Endpoint);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
fallbackText: null,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef,
|
||||
endpoint: context.Channel.Config.Endpoint);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context, string fallbackText)
|
||||
=> CreateBuilder(context, fallbackText).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
string? fallbackText,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef,
|
||||
string? endpoint)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("teams.webhook", target)
|
||||
.AddTimestamp("teams.preview.generatedAt", timestamp)
|
||||
.Add("teams.card.version", CardVersion)
|
||||
.AddSecretRefHash("teams.secretRef.hash", secretRef, SecretHashLengthBytes)
|
||||
.AddConfigTarget("teams.config.target", channel.Config.Target, target)
|
||||
.AddConfigEndpoint("teams.config.endpoint", endpoint)
|
||||
.AddConfigProperties("teams.config.", properties, (key, value) => RedactTeamsValue(builder, key, value));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallbackText))
|
||||
{
|
||||
builder.Add("teams.fallbackText", fallbackText!);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static string RedactTeamsValue(ConnectorMetadataBuilder builder, string key, string value)
|
||||
{
|
||||
if (ConnectorValueRedactor.IsSensitiveKey(key, builder.SensitiveFragments))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactSecret(value);
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (LooksLikeGuid(trimmed))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactToken(trimmed, prefixLength: 8, suffixLength: 4);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static bool LooksLikeGuid(string value)
|
||||
=> value.Length >= 32 && Guid.TryParse(value, out _);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Teams previews and health diagnostics while redacting sensitive material.
|
||||
/// </summary>
|
||||
internal static class TeamsMetadataBuilder
|
||||
{
|
||||
internal const string CardVersion = "1.5";
|
||||
|
||||
private const int SecretHashLengthBytes = 8;
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context, string fallbackText)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
fallbackText: fallbackText,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef,
|
||||
endpoint: context.Channel.Config.Endpoint);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
fallbackText: null,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef,
|
||||
endpoint: context.Channel.Config.Endpoint);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context, string fallbackText)
|
||||
=> CreateBuilder(context, fallbackText).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
string? fallbackText,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef,
|
||||
string? endpoint)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("teams.webhook", target)
|
||||
.AddTimestamp("teams.preview.generatedAt", timestamp)
|
||||
.Add("teams.card.version", CardVersion)
|
||||
.AddSecretRefHash("teams.secretRef.hash", secretRef, SecretHashLengthBytes)
|
||||
.AddConfigTarget("teams.config.target", channel.Config.Target, target)
|
||||
.AddConfigEndpoint("teams.config.endpoint", endpoint)
|
||||
.AddConfigProperties("teams.config.", properties, (key, value) => RedactTeamsValue(builder, key, value));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallbackText))
|
||||
{
|
||||
builder.Add("teams.fallbackText", fallbackText!);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static string RedactTeamsValue(ConnectorMetadataBuilder builder, string key, string value)
|
||||
{
|
||||
if (ConnectorValueRedactor.IsSensitiveKey(key, builder.SensitiveFragments))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactSecret(value);
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (LooksLikeGuid(trimmed))
|
||||
{
|
||||
return ConnectorValueRedactor.RedactToken(trimmed, prefixLength: 8, suffixLength: 4);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static bool LooksLikeGuid(string value)
|
||||
=> value.Length >= 32 && Guid.TryParse(value, out _);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Webhook;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class WebhookChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
|
||||
|
||||
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var title = context.Request.Title ?? "Stella Ops Notify Preview";
|
||||
var summary = context.Request.Summary ?? $"Preview generated at {context.Timestamp:O}.";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
title,
|
||||
summary,
|
||||
traceId = context.TraceId,
|
||||
timestamp = context.Timestamp,
|
||||
body = context.Request.Body,
|
||||
metadata = context.Request.Metadata
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Webhook,
|
||||
NotifyDeliveryFormat.Webhook,
|
||||
context.Target,
|
||||
title,
|
||||
body,
|
||||
summary,
|
||||
context.Request.TextBody ?? summary,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(body),
|
||||
context.Request.Attachments);
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Webhook;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class WebhookChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
|
||||
|
||||
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var title = context.Request.Title ?? "Stella Ops Notify Preview";
|
||||
var summary = context.Request.Summary ?? $"Preview generated at {context.Timestamp:O}.";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
title,
|
||||
summary,
|
||||
traceId = context.TraceId,
|
||||
timestamp = context.Timestamp,
|
||||
body = context.Request.Body,
|
||||
metadata = context.Request.Metadata
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Webhook,
|
||||
NotifyDeliveryFormat.Webhook,
|
||||
context.Target,
|
||||
title,
|
||||
body,
|
||||
summary,
|
||||
context.Request.TextBody ?? summary,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(body),
|
||||
context.Request.Attachments);
|
||||
|
||||
var metadata = WebhookMetadataBuilder.Build(context);
|
||||
|
||||
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Webhook;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Webhook previews and health diagnostics.
|
||||
/// </summary>
|
||||
internal static class WebhookMetadataBuilder
|
||||
{
|
||||
private const int SecretHashLengthBytes = 8;
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("webhook.endpoint", target)
|
||||
.AddTimestamp("webhook.preview.generatedAt", timestamp)
|
||||
.AddSecretRefHash("webhook.secretRef.hash", secretRef, SecretHashLengthBytes)
|
||||
.AddConfigProperties("webhook.config.", properties);
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Connectors.Shared;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Webhook;
|
||||
|
||||
/// <summary>
|
||||
/// Builds metadata for Webhook previews and health diagnostics.
|
||||
/// </summary>
|
||||
internal static class WebhookMetadataBuilder
|
||||
{
|
||||
private const int SecretHashLengthBytes = 8;
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
|
||||
=> CreateBaseBuilder(
|
||||
channel: context.Channel,
|
||||
target: context.Target,
|
||||
timestamp: context.Timestamp,
|
||||
properties: context.Channel.Config.Properties,
|
||||
secretRef: context.Channel.Config.SecretRef);
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
|
||||
=> CreateBuilder(context).Build();
|
||||
|
||||
private static ConnectorMetadataBuilder CreateBaseBuilder(
|
||||
NotifyChannel channel,
|
||||
string target,
|
||||
DateTimeOffset timestamp,
|
||||
IReadOnlyDictionary<string, string>? properties,
|
||||
string secretRef)
|
||||
{
|
||||
var builder = new ConnectorMetadataBuilder();
|
||||
|
||||
builder.AddTarget("webhook.endpoint", target)
|
||||
.AddTimestamp("webhook.preview.generatedAt", timestamp)
|
||||
.AddSecretRefHash("webhook.secretRef.hash", secretRef, SecretHashLengthBytes)
|
||||
.AddConfigProperties("webhook.config.", properties);
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Contract implemented by channel plug-ins to provide health diagnostics.
|
||||
/// </summary>
|
||||
public interface INotifyChannelHealthProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel type supported by the provider.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Executes a health check for the supplied channel.
|
||||
/// </summary>
|
||||
Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable context describing a channel health request.
|
||||
/// </summary>
|
||||
public sealed record ChannelHealthContext(
|
||||
string TenantId,
|
||||
NotifyChannel Channel,
|
||||
string Target,
|
||||
DateTimeOffset Timestamp,
|
||||
string TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Result returned by channel plug-ins when reporting health diagnostics.
|
||||
/// </summary>
|
||||
public sealed record ChannelHealthResult(
|
||||
ChannelHealthStatus Status,
|
||||
string? Message,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Supported channel health states.
|
||||
/// </summary>
|
||||
public enum ChannelHealthStatus
|
||||
{
|
||||
Healthy,
|
||||
Degraded,
|
||||
Unhealthy
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Contract implemented by channel plug-ins to provide health diagnostics.
|
||||
/// </summary>
|
||||
public interface INotifyChannelHealthProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel type supported by the provider.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Executes a health check for the supplied channel.
|
||||
/// </summary>
|
||||
Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable context describing a channel health request.
|
||||
/// </summary>
|
||||
public sealed record ChannelHealthContext(
|
||||
string TenantId,
|
||||
NotifyChannel Channel,
|
||||
string Target,
|
||||
DateTimeOffset Timestamp,
|
||||
string TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Result returned by channel plug-ins when reporting health diagnostics.
|
||||
/// </summary>
|
||||
public sealed record ChannelHealthResult(
|
||||
ChannelHealthStatus Status,
|
||||
string? Message,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Supported channel health states.
|
||||
/// </summary>
|
||||
public enum ChannelHealthStatus
|
||||
{
|
||||
Healthy,
|
||||
Degraded,
|
||||
Unhealthy
|
||||
}
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Contract implemented by Notify channel plug-ins to generate channel-specific test preview payloads.
|
||||
/// </summary>
|
||||
public interface INotifyChannelTestProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel type supported by the provider.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds a channel-specific preview for a test-send request.
|
||||
/// </summary>
|
||||
Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitised request payload passed to channel plug-ins when building a preview.
|
||||
/// </summary>
|
||||
public sealed record ChannelTestPreviewRequest(
|
||||
string? TargetOverride,
|
||||
string? TemplateId,
|
||||
string? Title,
|
||||
string? Summary,
|
||||
string? Body,
|
||||
string? TextBody,
|
||||
string? Locale,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
IReadOnlyList<string> Attachments);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable context describing the channel and request for a test preview.
|
||||
/// </summary>
|
||||
public sealed record ChannelTestPreviewContext(
|
||||
string TenantId,
|
||||
NotifyChannel Channel,
|
||||
string Target,
|
||||
ChannelTestPreviewRequest Request,
|
||||
DateTimeOffset Timestamp,
|
||||
string TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Result returned by channel plug-ins for test preview generation.
|
||||
/// </summary>
|
||||
public sealed record ChannelTestPreviewResult(
|
||||
NotifyDeliveryRendered Preview,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown by plug-ins when preview input is invalid.
|
||||
/// </summary>
|
||||
public sealed class ChannelTestPreviewException : Exception
|
||||
{
|
||||
public ChannelTestPreviewException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for channel preview generation.
|
||||
/// </summary>
|
||||
public static class ChannelTestPreviewUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a lowercase hex SHA-256 body hash for preview payloads.
|
||||
/// </summary>
|
||||
public static string ComputeBodyHash(string body)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Contract implemented by Notify channel plug-ins to generate channel-specific test preview payloads.
|
||||
/// </summary>
|
||||
public interface INotifyChannelTestProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel type supported by the provider.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds a channel-specific preview for a test-send request.
|
||||
/// </summary>
|
||||
Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitised request payload passed to channel plug-ins when building a preview.
|
||||
/// </summary>
|
||||
public sealed record ChannelTestPreviewRequest(
|
||||
string? TargetOverride,
|
||||
string? TemplateId,
|
||||
string? Title,
|
||||
string? Summary,
|
||||
string? Body,
|
||||
string? TextBody,
|
||||
string? Locale,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
IReadOnlyList<string> Attachments);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable context describing the channel and request for a test preview.
|
||||
/// </summary>
|
||||
public sealed record ChannelTestPreviewContext(
|
||||
string TenantId,
|
||||
NotifyChannel Channel,
|
||||
string Target,
|
||||
ChannelTestPreviewRequest Request,
|
||||
DateTimeOffset Timestamp,
|
||||
string TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Result returned by channel plug-ins for test preview generation.
|
||||
/// </summary>
|
||||
public sealed record ChannelTestPreviewResult(
|
||||
NotifyDeliveryRendered Preview,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown by plug-ins when preview input is invalid.
|
||||
/// </summary>
|
||||
public sealed class ChannelTestPreviewException : Exception
|
||||
{
|
||||
public ChannelTestPreviewException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for channel preview generation.
|
||||
/// </summary>
|
||||
public static class ChannelTestPreviewUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a lowercase hex SHA-256 body hash for preview payloads.
|
||||
/// </summary>
|
||||
public static string ComputeBodyHash(string body)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates Notify rules against platform events.
|
||||
/// </summary>
|
||||
public interface INotifyRuleEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a single rule against an event and returns the match outcome.
|
||||
/// </summary>
|
||||
NotifyRuleEvaluationOutcome Evaluate(
|
||||
NotifyRule rule,
|
||||
NotifyEvent @event,
|
||||
DateTimeOffset? evaluationTimestamp = null);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a collection of rules against an event.
|
||||
/// </summary>
|
||||
ImmutableArray<NotifyRuleEvaluationOutcome> Evaluate(
|
||||
IEnumerable<NotifyRule> rules,
|
||||
NotifyEvent @event,
|
||||
DateTimeOffset? evaluationTimestamp = null);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates Notify rules against platform events.
|
||||
/// </summary>
|
||||
public interface INotifyRuleEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a single rule against an event and returns the match outcome.
|
||||
/// </summary>
|
||||
NotifyRuleEvaluationOutcome Evaluate(
|
||||
NotifyRule rule,
|
||||
NotifyEvent @event,
|
||||
DateTimeOffset? evaluationTimestamp = null);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a collection of rules against an event.
|
||||
/// </summary>
|
||||
ImmutableArray<NotifyRuleEvaluationOutcome> Evaluate(
|
||||
IEnumerable<NotifyRule> rules,
|
||||
NotifyEvent @event,
|
||||
DateTimeOffset? evaluationTimestamp = null);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome produced when evaluating a notify rule against an event.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleEvaluationOutcome
|
||||
{
|
||||
private NotifyRuleEvaluationOutcome(
|
||||
NotifyRule rule,
|
||||
bool isMatch,
|
||||
ImmutableArray<NotifyRuleAction> actions,
|
||||
DateTimeOffset? matchedAt,
|
||||
string? reason)
|
||||
{
|
||||
Rule = rule ?? throw new ArgumentNullException(nameof(rule));
|
||||
IsMatch = isMatch;
|
||||
Actions = actions;
|
||||
MatchedAt = matchedAt;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
public NotifyRule Rule { get; }
|
||||
|
||||
public bool IsMatch { get; }
|
||||
|
||||
public ImmutableArray<NotifyRuleAction> Actions { get; }
|
||||
|
||||
public DateTimeOffset? MatchedAt { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public static NotifyRuleEvaluationOutcome NotMatched(NotifyRule rule, string reason)
|
||||
=> new(rule, false, ImmutableArray<NotifyRuleAction>.Empty, null, reason);
|
||||
|
||||
public static NotifyRuleEvaluationOutcome Matched(
|
||||
NotifyRule rule,
|
||||
ImmutableArray<NotifyRuleAction> actions,
|
||||
DateTimeOffset matchedAt)
|
||||
=> new(rule, true, actions, matchedAt, null);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome produced when evaluating a notify rule against an event.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleEvaluationOutcome
|
||||
{
|
||||
private NotifyRuleEvaluationOutcome(
|
||||
NotifyRule rule,
|
||||
bool isMatch,
|
||||
ImmutableArray<NotifyRuleAction> actions,
|
||||
DateTimeOffset? matchedAt,
|
||||
string? reason)
|
||||
{
|
||||
Rule = rule ?? throw new ArgumentNullException(nameof(rule));
|
||||
IsMatch = isMatch;
|
||||
Actions = actions;
|
||||
MatchedAt = matchedAt;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
public NotifyRule Rule { get; }
|
||||
|
||||
public bool IsMatch { get; }
|
||||
|
||||
public ImmutableArray<NotifyRuleAction> Actions { get; }
|
||||
|
||||
public DateTimeOffset? MatchedAt { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public static NotifyRuleEvaluationOutcome NotMatched(NotifyRule rule, string reason)
|
||||
=> new(rule, false, ImmutableArray<NotifyRuleAction>.Empty, null, reason);
|
||||
|
||||
public static NotifyRuleEvaluationOutcome Matched(
|
||||
NotifyRule rule,
|
||||
ImmutableArray<NotifyRuleAction> actions,
|
||||
DateTimeOffset matchedAt)
|
||||
=> new(rule, true, actions, matchedAt, null);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Xml;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
internal sealed class Iso8601DurationConverter : JsonConverter<TimeSpan>
|
||||
{
|
||||
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType is JsonTokenType.String)
|
||||
{
|
||||
var value = reader.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return XmlConvert.ToTimeSpan(value);
|
||||
}
|
||||
}
|
||||
|
||||
throw new JsonException("Expected ISO 8601 duration string.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
|
||||
{
|
||||
var normalized = XmlConvert.ToString(value);
|
||||
writer.WriteStringValue(normalized);
|
||||
}
|
||||
}
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Xml;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
internal sealed class Iso8601DurationConverter : JsonConverter<TimeSpan>
|
||||
{
|
||||
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType is JsonTokenType.String)
|
||||
{
|
||||
var value = reader.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return XmlConvert.ToTimeSpan(value);
|
||||
}
|
||||
}
|
||||
|
||||
throw new JsonException("Expected ISO 8601 duration string.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
|
||||
{
|
||||
var normalized = XmlConvert.ToString(value);
|
||||
writer.WriteStringValue(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,235 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Configured delivery channel (Slack workspace, Teams webhook, SMTP profile, etc.).
|
||||
/// </summary>
|
||||
public sealed record NotifyChannel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyChannel(
|
||||
string channelId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyChannelType type,
|
||||
NotifyChannelConfig config,
|
||||
string? displayName = null,
|
||||
string? description = null,
|
||||
bool enabled = true,
|
||||
ImmutableDictionary<string, string>? labels = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
SchemaVersion = NotifySchemaVersions.EnsureChannel(schemaVersion);
|
||||
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Type = type;
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
DisplayName = NotifyValidation.TrimToNull(displayName);
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Enabled = enabled;
|
||||
|
||||
Labels = NotifyValidation.NormalizeStringDictionary(labels);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyChannel Create(
|
||||
string channelId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyChannelType type,
|
||||
NotifyChannelConfig config,
|
||||
string? displayName = null,
|
||||
string? description = null,
|
||||
bool enabled = true,
|
||||
IEnumerable<KeyValuePair<string, string>>? labels = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
return new NotifyChannel(
|
||||
channelId,
|
||||
tenantId,
|
||||
name,
|
||||
type,
|
||||
config,
|
||||
displayName,
|
||||
description,
|
||||
enabled,
|
||||
ToImmutableDictionary(labels),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string ChannelId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public NotifyChannelType Type { get; }
|
||||
|
||||
public NotifyChannelConfig Config { get; }
|
||||
|
||||
public string? DisplayName { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Labels { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel configuration payload (secret reference, destination coordinates, connector-specific metadata).
|
||||
/// </summary>
|
||||
public sealed record NotifyChannelConfig
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyChannelConfig(
|
||||
string secretRef,
|
||||
string? target = null,
|
||||
string? endpoint = null,
|
||||
ImmutableDictionary<string, string>? properties = null,
|
||||
NotifyChannelLimits? limits = null)
|
||||
{
|
||||
SecretRef = NotifyValidation.EnsureNotNullOrWhiteSpace(secretRef, nameof(secretRef));
|
||||
Target = NotifyValidation.TrimToNull(target);
|
||||
Endpoint = NotifyValidation.TrimToNull(endpoint);
|
||||
Properties = NotifyValidation.NormalizeStringDictionary(properties);
|
||||
Limits = limits;
|
||||
}
|
||||
|
||||
public static NotifyChannelConfig Create(
|
||||
string secretRef,
|
||||
string? target = null,
|
||||
string? endpoint = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? properties = null,
|
||||
NotifyChannelLimits? limits = null)
|
||||
{
|
||||
return new NotifyChannelConfig(
|
||||
secretRef,
|
||||
target,
|
||||
endpoint,
|
||||
ToImmutableDictionary(properties),
|
||||
limits);
|
||||
}
|
||||
|
||||
public string SecretRef { get; }
|
||||
|
||||
public string? Target { get; }
|
||||
|
||||
public string? Endpoint { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Properties { get; }
|
||||
|
||||
public NotifyChannelLimits? Limits { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-channel limits that influence worker behaviour.
|
||||
/// </summary>
|
||||
public sealed record NotifyChannelLimits
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyChannelLimits(
|
||||
int? concurrency = null,
|
||||
int? requestsPerMinute = null,
|
||||
TimeSpan? timeout = null,
|
||||
int? maxBatchSize = null)
|
||||
{
|
||||
if (concurrency is < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(concurrency), "Concurrency must be positive when specified.");
|
||||
}
|
||||
|
||||
if (requestsPerMinute is < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(requestsPerMinute), "Requests per minute must be positive when specified.");
|
||||
}
|
||||
|
||||
if (maxBatchSize is < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxBatchSize), "Max batch size must be positive when specified.");
|
||||
}
|
||||
|
||||
Concurrency = concurrency;
|
||||
RequestsPerMinute = requestsPerMinute;
|
||||
Timeout = timeout is { Ticks: > 0 } ? timeout : null;
|
||||
MaxBatchSize = maxBatchSize;
|
||||
}
|
||||
|
||||
public int? Concurrency { get; }
|
||||
|
||||
public int? RequestsPerMinute { get; }
|
||||
|
||||
public TimeSpan? Timeout { get; }
|
||||
|
||||
public int? MaxBatchSize { get; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Configured delivery channel (Slack workspace, Teams webhook, SMTP profile, etc.).
|
||||
/// </summary>
|
||||
public sealed record NotifyChannel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyChannel(
|
||||
string channelId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyChannelType type,
|
||||
NotifyChannelConfig config,
|
||||
string? displayName = null,
|
||||
string? description = null,
|
||||
bool enabled = true,
|
||||
ImmutableDictionary<string, string>? labels = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
SchemaVersion = NotifySchemaVersions.EnsureChannel(schemaVersion);
|
||||
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Type = type;
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
DisplayName = NotifyValidation.TrimToNull(displayName);
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Enabled = enabled;
|
||||
|
||||
Labels = NotifyValidation.NormalizeStringDictionary(labels);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyChannel Create(
|
||||
string channelId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyChannelType type,
|
||||
NotifyChannelConfig config,
|
||||
string? displayName = null,
|
||||
string? description = null,
|
||||
bool enabled = true,
|
||||
IEnumerable<KeyValuePair<string, string>>? labels = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
return new NotifyChannel(
|
||||
channelId,
|
||||
tenantId,
|
||||
name,
|
||||
type,
|
||||
config,
|
||||
displayName,
|
||||
description,
|
||||
enabled,
|
||||
ToImmutableDictionary(labels),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string ChannelId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public NotifyChannelType Type { get; }
|
||||
|
||||
public NotifyChannelConfig Config { get; }
|
||||
|
||||
public string? DisplayName { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Labels { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel configuration payload (secret reference, destination coordinates, connector-specific metadata).
|
||||
/// </summary>
|
||||
public sealed record NotifyChannelConfig
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyChannelConfig(
|
||||
string secretRef,
|
||||
string? target = null,
|
||||
string? endpoint = null,
|
||||
ImmutableDictionary<string, string>? properties = null,
|
||||
NotifyChannelLimits? limits = null)
|
||||
{
|
||||
SecretRef = NotifyValidation.EnsureNotNullOrWhiteSpace(secretRef, nameof(secretRef));
|
||||
Target = NotifyValidation.TrimToNull(target);
|
||||
Endpoint = NotifyValidation.TrimToNull(endpoint);
|
||||
Properties = NotifyValidation.NormalizeStringDictionary(properties);
|
||||
Limits = limits;
|
||||
}
|
||||
|
||||
public static NotifyChannelConfig Create(
|
||||
string secretRef,
|
||||
string? target = null,
|
||||
string? endpoint = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? properties = null,
|
||||
NotifyChannelLimits? limits = null)
|
||||
{
|
||||
return new NotifyChannelConfig(
|
||||
secretRef,
|
||||
target,
|
||||
endpoint,
|
||||
ToImmutableDictionary(properties),
|
||||
limits);
|
||||
}
|
||||
|
||||
public string SecretRef { get; }
|
||||
|
||||
public string? Target { get; }
|
||||
|
||||
public string? Endpoint { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Properties { get; }
|
||||
|
||||
public NotifyChannelLimits? Limits { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-channel limits that influence worker behaviour.
|
||||
/// </summary>
|
||||
public sealed record NotifyChannelLimits
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyChannelLimits(
|
||||
int? concurrency = null,
|
||||
int? requestsPerMinute = null,
|
||||
TimeSpan? timeout = null,
|
||||
int? maxBatchSize = null)
|
||||
{
|
||||
if (concurrency is < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(concurrency), "Concurrency must be positive when specified.");
|
||||
}
|
||||
|
||||
if (requestsPerMinute is < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(requestsPerMinute), "Requests per minute must be positive when specified.");
|
||||
}
|
||||
|
||||
if (maxBatchSize is < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxBatchSize), "Max batch size must be positive when specified.");
|
||||
}
|
||||
|
||||
Concurrency = concurrency;
|
||||
RequestsPerMinute = requestsPerMinute;
|
||||
Timeout = timeout is { Ticks: > 0 } ? timeout : null;
|
||||
MaxBatchSize = maxBatchSize;
|
||||
}
|
||||
|
||||
public int? Concurrency { get; }
|
||||
|
||||
public int? RequestsPerMinute { get; }
|
||||
|
||||
public TimeSpan? Timeout { get; }
|
||||
|
||||
public int? MaxBatchSize { get; }
|
||||
}
|
||||
|
||||
@@ -1,252 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Delivery ledger entry capturing render output, attempts, and status transitions.
|
||||
/// </summary>
|
||||
public sealed record NotifyDelivery
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyDelivery(
|
||||
string deliveryId,
|
||||
string tenantId,
|
||||
string ruleId,
|
||||
string actionId,
|
||||
Guid eventId,
|
||||
string kind,
|
||||
NotifyDeliveryStatus status,
|
||||
string? statusReason = null,
|
||||
NotifyDeliveryRendered? rendered = null,
|
||||
ImmutableArray<NotifyDeliveryAttempt> attempts = default,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? sentAt = null,
|
||||
DateTimeOffset? completedAt = null)
|
||||
{
|
||||
DeliveryId = NotifyValidation.EnsureNotNullOrWhiteSpace(deliveryId, nameof(deliveryId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
RuleId = NotifyValidation.EnsureNotNullOrWhiteSpace(ruleId, nameof(ruleId));
|
||||
ActionId = NotifyValidation.EnsureNotNullOrWhiteSpace(actionId, nameof(actionId));
|
||||
EventId = eventId;
|
||||
Kind = NotifyValidation.EnsureNotNullOrWhiteSpace(kind, nameof(kind)).ToLowerInvariant();
|
||||
Status = status;
|
||||
StatusReason = NotifyValidation.TrimToNull(statusReason);
|
||||
Rendered = rendered;
|
||||
|
||||
Attempts = NormalizeAttempts(attempts);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
SentAt = NotifyValidation.EnsureUtc(sentAt);
|
||||
CompletedAt = NotifyValidation.EnsureUtc(completedAt);
|
||||
}
|
||||
|
||||
public static NotifyDelivery Create(
|
||||
string deliveryId,
|
||||
string tenantId,
|
||||
string ruleId,
|
||||
string actionId,
|
||||
Guid eventId,
|
||||
string kind,
|
||||
NotifyDeliveryStatus status,
|
||||
string? statusReason = null,
|
||||
NotifyDeliveryRendered? rendered = null,
|
||||
IEnumerable<NotifyDeliveryAttempt>? attempts = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? sentAt = null,
|
||||
DateTimeOffset? completedAt = null)
|
||||
{
|
||||
return new NotifyDelivery(
|
||||
deliveryId,
|
||||
tenantId,
|
||||
ruleId,
|
||||
actionId,
|
||||
eventId,
|
||||
kind,
|
||||
status,
|
||||
statusReason,
|
||||
rendered,
|
||||
ToImmutableArray(attempts),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdAt,
|
||||
sentAt,
|
||||
completedAt);
|
||||
}
|
||||
|
||||
public string DeliveryId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string RuleId { get; }
|
||||
|
||||
public string ActionId { get; }
|
||||
|
||||
public Guid EventId { get; }
|
||||
|
||||
public string Kind { get; }
|
||||
|
||||
public NotifyDeliveryStatus Status { get; }
|
||||
|
||||
public string? StatusReason { get; }
|
||||
|
||||
public NotifyDeliveryRendered? Rendered { get; }
|
||||
|
||||
public ImmutableArray<NotifyDeliveryAttempt> Attempts { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public DateTimeOffset? SentAt { get; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; }
|
||||
|
||||
private static ImmutableArray<NotifyDeliveryAttempt> NormalizeAttempts(ImmutableArray<NotifyDeliveryAttempt> attempts)
|
||||
{
|
||||
var source = attempts.IsDefault ? Array.Empty<NotifyDeliveryAttempt>() : attempts.AsEnumerable();
|
||||
return source
|
||||
.Where(static attempt => attempt is not null)
|
||||
.OrderBy(static attempt => attempt.Timestamp)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyDeliveryAttempt> ToImmutableArray(IEnumerable<NotifyDeliveryAttempt>? attempts)
|
||||
{
|
||||
if (attempts is null)
|
||||
{
|
||||
return ImmutableArray<NotifyDeliveryAttempt>.Empty;
|
||||
}
|
||||
|
||||
return attempts.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual delivery attempt outcome.
|
||||
/// </summary>
|
||||
public sealed record NotifyDeliveryAttempt
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyDeliveryAttempt(
|
||||
DateTimeOffset timestamp,
|
||||
NotifyDeliveryAttemptStatus status,
|
||||
int? statusCode = null,
|
||||
string? reason = null)
|
||||
{
|
||||
Timestamp = NotifyValidation.EnsureUtc(timestamp);
|
||||
Status = status;
|
||||
if (statusCode is < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(statusCode), "Status code must be positive when specified.");
|
||||
}
|
||||
|
||||
StatusCode = statusCode;
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
}
|
||||
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public NotifyDeliveryAttemptStatus Status { get; }
|
||||
|
||||
public int? StatusCode { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rendered payload snapshot for audit purposes (redacted as needed).
|
||||
/// </summary>
|
||||
public sealed record NotifyDeliveryRendered
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyDeliveryRendered(
|
||||
NotifyChannelType channelType,
|
||||
NotifyDeliveryFormat format,
|
||||
string target,
|
||||
string title,
|
||||
string body,
|
||||
string? summary = null,
|
||||
string? textBody = null,
|
||||
string? locale = null,
|
||||
string? bodyHash = null,
|
||||
ImmutableArray<string> attachments = default)
|
||||
{
|
||||
ChannelType = channelType;
|
||||
Format = format;
|
||||
Target = NotifyValidation.EnsureNotNullOrWhiteSpace(target, nameof(target));
|
||||
Title = NotifyValidation.EnsureNotNullOrWhiteSpace(title, nameof(title));
|
||||
Body = NotifyValidation.EnsureNotNullOrWhiteSpace(body, nameof(body));
|
||||
Summary = NotifyValidation.TrimToNull(summary);
|
||||
TextBody = NotifyValidation.TrimToNull(textBody);
|
||||
Locale = NotifyValidation.TrimToNull(locale)?.ToLowerInvariant();
|
||||
BodyHash = NotifyValidation.TrimToNull(bodyHash);
|
||||
Attachments = NotifyValidation.NormalizeStringSet(attachments.IsDefault ? Array.Empty<string>() : attachments.AsEnumerable());
|
||||
}
|
||||
|
||||
public static NotifyDeliveryRendered Create(
|
||||
NotifyChannelType channelType,
|
||||
NotifyDeliveryFormat format,
|
||||
string target,
|
||||
string title,
|
||||
string body,
|
||||
string? summary = null,
|
||||
string? textBody = null,
|
||||
string? locale = null,
|
||||
string? bodyHash = null,
|
||||
IEnumerable<string>? attachments = null)
|
||||
{
|
||||
return new NotifyDeliveryRendered(
|
||||
channelType,
|
||||
format,
|
||||
target,
|
||||
title,
|
||||
body,
|
||||
summary,
|
||||
textBody,
|
||||
locale,
|
||||
bodyHash,
|
||||
attachments is null ? ImmutableArray<string>.Empty : attachments.ToImmutableArray());
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType { get; }
|
||||
|
||||
public NotifyDeliveryFormat Format { get; }
|
||||
|
||||
public string Target { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
public string? Summary { get; }
|
||||
|
||||
public string? TextBody { get; }
|
||||
|
||||
public string? Locale { get; }
|
||||
|
||||
public string? BodyHash { get; }
|
||||
|
||||
public ImmutableArray<string> Attachments { get; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Delivery ledger entry capturing render output, attempts, and status transitions.
|
||||
/// </summary>
|
||||
public sealed record NotifyDelivery
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyDelivery(
|
||||
string deliveryId,
|
||||
string tenantId,
|
||||
string ruleId,
|
||||
string actionId,
|
||||
Guid eventId,
|
||||
string kind,
|
||||
NotifyDeliveryStatus status,
|
||||
string? statusReason = null,
|
||||
NotifyDeliveryRendered? rendered = null,
|
||||
ImmutableArray<NotifyDeliveryAttempt> attempts = default,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? sentAt = null,
|
||||
DateTimeOffset? completedAt = null)
|
||||
{
|
||||
DeliveryId = NotifyValidation.EnsureNotNullOrWhiteSpace(deliveryId, nameof(deliveryId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
RuleId = NotifyValidation.EnsureNotNullOrWhiteSpace(ruleId, nameof(ruleId));
|
||||
ActionId = NotifyValidation.EnsureNotNullOrWhiteSpace(actionId, nameof(actionId));
|
||||
EventId = eventId;
|
||||
Kind = NotifyValidation.EnsureNotNullOrWhiteSpace(kind, nameof(kind)).ToLowerInvariant();
|
||||
Status = status;
|
||||
StatusReason = NotifyValidation.TrimToNull(statusReason);
|
||||
Rendered = rendered;
|
||||
|
||||
Attempts = NormalizeAttempts(attempts);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
SentAt = NotifyValidation.EnsureUtc(sentAt);
|
||||
CompletedAt = NotifyValidation.EnsureUtc(completedAt);
|
||||
}
|
||||
|
||||
public static NotifyDelivery Create(
|
||||
string deliveryId,
|
||||
string tenantId,
|
||||
string ruleId,
|
||||
string actionId,
|
||||
Guid eventId,
|
||||
string kind,
|
||||
NotifyDeliveryStatus status,
|
||||
string? statusReason = null,
|
||||
NotifyDeliveryRendered? rendered = null,
|
||||
IEnumerable<NotifyDeliveryAttempt>? attempts = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? sentAt = null,
|
||||
DateTimeOffset? completedAt = null)
|
||||
{
|
||||
return new NotifyDelivery(
|
||||
deliveryId,
|
||||
tenantId,
|
||||
ruleId,
|
||||
actionId,
|
||||
eventId,
|
||||
kind,
|
||||
status,
|
||||
statusReason,
|
||||
rendered,
|
||||
ToImmutableArray(attempts),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdAt,
|
||||
sentAt,
|
||||
completedAt);
|
||||
}
|
||||
|
||||
public string DeliveryId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string RuleId { get; }
|
||||
|
||||
public string ActionId { get; }
|
||||
|
||||
public Guid EventId { get; }
|
||||
|
||||
public string Kind { get; }
|
||||
|
||||
public NotifyDeliveryStatus Status { get; }
|
||||
|
||||
public string? StatusReason { get; }
|
||||
|
||||
public NotifyDeliveryRendered? Rendered { get; }
|
||||
|
||||
public ImmutableArray<NotifyDeliveryAttempt> Attempts { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public DateTimeOffset? SentAt { get; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; }
|
||||
|
||||
private static ImmutableArray<NotifyDeliveryAttempt> NormalizeAttempts(ImmutableArray<NotifyDeliveryAttempt> attempts)
|
||||
{
|
||||
var source = attempts.IsDefault ? Array.Empty<NotifyDeliveryAttempt>() : attempts.AsEnumerable();
|
||||
return source
|
||||
.Where(static attempt => attempt is not null)
|
||||
.OrderBy(static attempt => attempt.Timestamp)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyDeliveryAttempt> ToImmutableArray(IEnumerable<NotifyDeliveryAttempt>? attempts)
|
||||
{
|
||||
if (attempts is null)
|
||||
{
|
||||
return ImmutableArray<NotifyDeliveryAttempt>.Empty;
|
||||
}
|
||||
|
||||
return attempts.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual delivery attempt outcome.
|
||||
/// </summary>
|
||||
public sealed record NotifyDeliveryAttempt
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyDeliveryAttempt(
|
||||
DateTimeOffset timestamp,
|
||||
NotifyDeliveryAttemptStatus status,
|
||||
int? statusCode = null,
|
||||
string? reason = null)
|
||||
{
|
||||
Timestamp = NotifyValidation.EnsureUtc(timestamp);
|
||||
Status = status;
|
||||
if (statusCode is < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(statusCode), "Status code must be positive when specified.");
|
||||
}
|
||||
|
||||
StatusCode = statusCode;
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
}
|
||||
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public NotifyDeliveryAttemptStatus Status { get; }
|
||||
|
||||
public int? StatusCode { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rendered payload snapshot for audit purposes (redacted as needed).
|
||||
/// </summary>
|
||||
public sealed record NotifyDeliveryRendered
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyDeliveryRendered(
|
||||
NotifyChannelType channelType,
|
||||
NotifyDeliveryFormat format,
|
||||
string target,
|
||||
string title,
|
||||
string body,
|
||||
string? summary = null,
|
||||
string? textBody = null,
|
||||
string? locale = null,
|
||||
string? bodyHash = null,
|
||||
ImmutableArray<string> attachments = default)
|
||||
{
|
||||
ChannelType = channelType;
|
||||
Format = format;
|
||||
Target = NotifyValidation.EnsureNotNullOrWhiteSpace(target, nameof(target));
|
||||
Title = NotifyValidation.EnsureNotNullOrWhiteSpace(title, nameof(title));
|
||||
Body = NotifyValidation.EnsureNotNullOrWhiteSpace(body, nameof(body));
|
||||
Summary = NotifyValidation.TrimToNull(summary);
|
||||
TextBody = NotifyValidation.TrimToNull(textBody);
|
||||
Locale = NotifyValidation.TrimToNull(locale)?.ToLowerInvariant();
|
||||
BodyHash = NotifyValidation.TrimToNull(bodyHash);
|
||||
Attachments = NotifyValidation.NormalizeStringSet(attachments.IsDefault ? Array.Empty<string>() : attachments.AsEnumerable());
|
||||
}
|
||||
|
||||
public static NotifyDeliveryRendered Create(
|
||||
NotifyChannelType channelType,
|
||||
NotifyDeliveryFormat format,
|
||||
string target,
|
||||
string title,
|
||||
string body,
|
||||
string? summary = null,
|
||||
string? textBody = null,
|
||||
string? locale = null,
|
||||
string? bodyHash = null,
|
||||
IEnumerable<string>? attachments = null)
|
||||
{
|
||||
return new NotifyDeliveryRendered(
|
||||
channelType,
|
||||
format,
|
||||
target,
|
||||
title,
|
||||
body,
|
||||
summary,
|
||||
textBody,
|
||||
locale,
|
||||
bodyHash,
|
||||
attachments is null ? ImmutableArray<string>.Empty : attachments.ToImmutableArray());
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType { get; }
|
||||
|
||||
public NotifyDeliveryFormat Format { get; }
|
||||
|
||||
public string Target { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
public string? Summary { get; }
|
||||
|
||||
public string? TextBody { get; }
|
||||
|
||||
public string? Locale { get; }
|
||||
|
||||
public string? BodyHash { get; }
|
||||
|
||||
public ImmutableArray<string> Attachments { get; }
|
||||
}
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Supported Notify channel types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyChannelType
|
||||
{
|
||||
Slack,
|
||||
Teams,
|
||||
Email,
|
||||
Webhook,
|
||||
Custom,
|
||||
PagerDuty,
|
||||
OpsGenie,
|
||||
Cli,
|
||||
InAppInbox,
|
||||
InApp,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery lifecycle states tracked for audit and retries.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyDeliveryStatus
|
||||
{
|
||||
Pending,
|
||||
Queued,
|
||||
Sending,
|
||||
Delivered,
|
||||
Sent,
|
||||
Failed,
|
||||
Throttled,
|
||||
Digested,
|
||||
Dropped,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual attempt status recorded during delivery retries.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyDeliveryAttemptStatus
|
||||
{
|
||||
Enqueued,
|
||||
Sending,
|
||||
Succeeded,
|
||||
Success = Succeeded,
|
||||
Failed,
|
||||
Throttled,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rendering modes for templates to help connectors decide format handling.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyTemplateRenderMode
|
||||
{
|
||||
Markdown,
|
||||
Html,
|
||||
AdaptiveCard,
|
||||
PlainText,
|
||||
Json,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured representation of rendered payload format.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyDeliveryFormat
|
||||
{
|
||||
Markdown,
|
||||
Html,
|
||||
PlainText,
|
||||
Slack,
|
||||
Teams,
|
||||
Email,
|
||||
Webhook,
|
||||
Json,
|
||||
PagerDuty,
|
||||
OpsGenie,
|
||||
Cli,
|
||||
InAppInbox,
|
||||
}
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Supported Notify channel types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyChannelType
|
||||
{
|
||||
Slack,
|
||||
Teams,
|
||||
Email,
|
||||
Webhook,
|
||||
Custom,
|
||||
PagerDuty,
|
||||
OpsGenie,
|
||||
Cli,
|
||||
InAppInbox,
|
||||
InApp,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery lifecycle states tracked for audit and retries.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyDeliveryStatus
|
||||
{
|
||||
Pending,
|
||||
Queued,
|
||||
Sending,
|
||||
Delivered,
|
||||
Sent,
|
||||
Failed,
|
||||
Throttled,
|
||||
Digested,
|
||||
Dropped,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual attempt status recorded during delivery retries.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyDeliveryAttemptStatus
|
||||
{
|
||||
Enqueued,
|
||||
Sending,
|
||||
Succeeded,
|
||||
Success = Succeeded,
|
||||
Failed,
|
||||
Throttled,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rendering modes for templates to help connectors decide format handling.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyTemplateRenderMode
|
||||
{
|
||||
Markdown,
|
||||
Html,
|
||||
AdaptiveCard,
|
||||
PlainText,
|
||||
Json,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured representation of rendered payload format.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyDeliveryFormat
|
||||
{
|
||||
Markdown,
|
||||
Html,
|
||||
PlainText,
|
||||
Slack,
|
||||
Teams,
|
||||
Email,
|
||||
Webhook,
|
||||
Json,
|
||||
PagerDuty,
|
||||
OpsGenie,
|
||||
Cli,
|
||||
InAppInbox,
|
||||
}
|
||||
|
||||
@@ -1,478 +1,478 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Escalation policy defining how incidents are escalated through multiple levels.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationPolicy
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationPolicy(
|
||||
string policyId,
|
||||
string tenantId,
|
||||
string name,
|
||||
ImmutableArray<NotifyEscalationLevel> levels,
|
||||
bool enabled = true,
|
||||
bool repeatEnabled = false,
|
||||
int? repeatCount = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
PolicyId = NotifyValidation.EnsureNotNullOrWhiteSpace(policyId, nameof(policyId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Levels = NormalizeLevels(levels);
|
||||
|
||||
if (Levels.IsDefaultOrEmpty)
|
||||
{
|
||||
throw new ArgumentException("At least one escalation level is required.", nameof(levels));
|
||||
}
|
||||
|
||||
Enabled = enabled;
|
||||
RepeatEnabled = repeatEnabled;
|
||||
RepeatCount = repeatCount is > 0 ? repeatCount : null;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyEscalationPolicy Create(
|
||||
string policyId,
|
||||
string tenantId,
|
||||
string name,
|
||||
IEnumerable<NotifyEscalationLevel>? levels,
|
||||
bool enabled = true,
|
||||
bool repeatEnabled = false,
|
||||
int? repeatCount = null,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyEscalationPolicy(
|
||||
policyId,
|
||||
tenantId,
|
||||
name,
|
||||
ToImmutableArray(levels),
|
||||
enabled,
|
||||
repeatEnabled,
|
||||
repeatCount,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string PolicyId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of escalation levels.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationLevel> Levels { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to repeat the escalation cycle after reaching the last level.
|
||||
/// </summary>
|
||||
public bool RepeatEnabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of times to repeat the escalation cycle.
|
||||
/// </summary>
|
||||
public int? RepeatCount { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableArray<NotifyEscalationLevel> NormalizeLevels(ImmutableArray<NotifyEscalationLevel> levels)
|
||||
{
|
||||
if (levels.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<NotifyEscalationLevel>.Empty;
|
||||
}
|
||||
|
||||
return levels
|
||||
.Where(static l => l is not null)
|
||||
.OrderBy(static l => l.Order)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyEscalationLevel> ToImmutableArray(IEnumerable<NotifyEscalationLevel>? levels)
|
||||
=> levels is null ? ImmutableArray<NotifyEscalationLevel>.Empty : levels.ToImmutableArray();
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single level in an escalation policy.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationLevel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationLevel(
|
||||
int order,
|
||||
TimeSpan escalateAfter,
|
||||
ImmutableArray<NotifyEscalationTarget> targets,
|
||||
string? name = null,
|
||||
bool notifyAll = true)
|
||||
{
|
||||
Order = order >= 0 ? order : 0;
|
||||
EscalateAfter = escalateAfter > TimeSpan.Zero ? escalateAfter : TimeSpan.FromMinutes(15);
|
||||
Targets = NormalizeTargets(targets);
|
||||
Name = NotifyValidation.TrimToNull(name);
|
||||
NotifyAll = notifyAll;
|
||||
}
|
||||
|
||||
public static NotifyEscalationLevel Create(
|
||||
int order,
|
||||
TimeSpan escalateAfter,
|
||||
IEnumerable<NotifyEscalationTarget>? targets,
|
||||
string? name = null,
|
||||
bool notifyAll = true)
|
||||
{
|
||||
return new NotifyEscalationLevel(
|
||||
order,
|
||||
escalateAfter,
|
||||
ToImmutableArray(targets),
|
||||
name,
|
||||
notifyAll);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order of this level in the escalation chain (0-based).
|
||||
/// </summary>
|
||||
public int Order { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Time to wait before escalating to this level.
|
||||
/// </summary>
|
||||
public TimeSpan EscalateAfter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Targets to notify at this level.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationTarget> Targets { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional name for this level (e.g., "Primary", "Secondary", "Management").
|
||||
/// </summary>
|
||||
public string? Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to notify all targets at this level, or just the first available.
|
||||
/// </summary>
|
||||
public bool NotifyAll { get; }
|
||||
|
||||
private static ImmutableArray<NotifyEscalationTarget> NormalizeTargets(ImmutableArray<NotifyEscalationTarget> targets)
|
||||
{
|
||||
if (targets.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<NotifyEscalationTarget>.Empty;
|
||||
}
|
||||
|
||||
return targets
|
||||
.Where(static t => t is not null)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyEscalationTarget> ToImmutableArray(IEnumerable<NotifyEscalationTarget>? targets)
|
||||
=> targets is null ? ImmutableArray<NotifyEscalationTarget>.Empty : targets.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Target to notify during escalation.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationTarget
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationTarget(
|
||||
NotifyEscalationTargetType type,
|
||||
string targetId,
|
||||
string? channelOverride = null)
|
||||
{
|
||||
Type = type;
|
||||
TargetId = NotifyValidation.EnsureNotNullOrWhiteSpace(targetId, nameof(targetId));
|
||||
ChannelOverride = NotifyValidation.TrimToNull(channelOverride);
|
||||
}
|
||||
|
||||
public static NotifyEscalationTarget Create(
|
||||
NotifyEscalationTargetType type,
|
||||
string targetId,
|
||||
string? channelOverride = null)
|
||||
{
|
||||
return new NotifyEscalationTarget(type, targetId, channelOverride);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of target (user, on-call schedule, channel, external service).
|
||||
/// </summary>
|
||||
public NotifyEscalationTargetType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the target (user ID, schedule ID, channel ID, or external service ID).
|
||||
/// </summary>
|
||||
public string TargetId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel override for this target.
|
||||
/// </summary>
|
||||
public string? ChannelOverride { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of escalation target.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyEscalationTargetType
|
||||
{
|
||||
/// <summary>
|
||||
/// A specific user.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// An on-call schedule (resolves to current on-call user).
|
||||
/// </summary>
|
||||
OnCallSchedule,
|
||||
|
||||
/// <summary>
|
||||
/// A notification channel directly.
|
||||
/// </summary>
|
||||
Channel,
|
||||
|
||||
/// <summary>
|
||||
/// External service (PagerDuty, OpsGenie, etc.).
|
||||
/// </summary>
|
||||
ExternalService,
|
||||
|
||||
/// <summary>
|
||||
/// In-app inbox notification.
|
||||
/// </summary>
|
||||
InAppInbox
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the current state of an escalation for an incident.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationState
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationState(
|
||||
string stateId,
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
int currentLevel,
|
||||
int repeatIteration,
|
||||
NotifyEscalationStatus status,
|
||||
ImmutableArray<NotifyEscalationAttempt> attempts,
|
||||
DateTimeOffset? nextEscalationAt = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
DateTimeOffset? acknowledgedAt = null,
|
||||
string? acknowledgedBy = null,
|
||||
DateTimeOffset? resolvedAt = null,
|
||||
string? resolvedBy = null)
|
||||
{
|
||||
StateId = NotifyValidation.EnsureNotNullOrWhiteSpace(stateId, nameof(stateId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
IncidentId = NotifyValidation.EnsureNotNullOrWhiteSpace(incidentId, nameof(incidentId));
|
||||
PolicyId = NotifyValidation.EnsureNotNullOrWhiteSpace(policyId, nameof(policyId));
|
||||
CurrentLevel = currentLevel >= 0 ? currentLevel : 0;
|
||||
RepeatIteration = repeatIteration >= 0 ? repeatIteration : 0;
|
||||
Status = status;
|
||||
Attempts = attempts.IsDefault ? ImmutableArray<NotifyEscalationAttempt>.Empty : attempts;
|
||||
NextEscalationAt = NotifyValidation.EnsureUtc(nextEscalationAt);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
AcknowledgedAt = NotifyValidation.EnsureUtc(acknowledgedAt);
|
||||
AcknowledgedBy = NotifyValidation.TrimToNull(acknowledgedBy);
|
||||
ResolvedAt = NotifyValidation.EnsureUtc(resolvedAt);
|
||||
ResolvedBy = NotifyValidation.TrimToNull(resolvedBy);
|
||||
}
|
||||
|
||||
public static NotifyEscalationState Create(
|
||||
string stateId,
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
int currentLevel = 0,
|
||||
int repeatIteration = 0,
|
||||
NotifyEscalationStatus status = NotifyEscalationStatus.Active,
|
||||
IEnumerable<NotifyEscalationAttempt>? attempts = null,
|
||||
DateTimeOffset? nextEscalationAt = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
DateTimeOffset? acknowledgedAt = null,
|
||||
string? acknowledgedBy = null,
|
||||
DateTimeOffset? resolvedAt = null,
|
||||
string? resolvedBy = null)
|
||||
{
|
||||
return new NotifyEscalationState(
|
||||
stateId,
|
||||
tenantId,
|
||||
incidentId,
|
||||
policyId,
|
||||
currentLevel,
|
||||
repeatIteration,
|
||||
status,
|
||||
attempts?.ToImmutableArray() ?? ImmutableArray<NotifyEscalationAttempt>.Empty,
|
||||
nextEscalationAt,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
acknowledgedAt,
|
||||
acknowledgedBy,
|
||||
resolvedAt,
|
||||
resolvedBy);
|
||||
}
|
||||
|
||||
public string StateId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string IncidentId { get; }
|
||||
|
||||
public string PolicyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current escalation level (0-based index).
|
||||
/// </summary>
|
||||
public int CurrentLevel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current repeat iteration (0 = first pass through levels).
|
||||
/// </summary>
|
||||
public int RepeatIteration { get; }
|
||||
|
||||
public NotifyEscalationStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// History of escalation attempts.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationAttempt> Attempts { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the next escalation will occur.
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextEscalationAt { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
public DateTimeOffset? AcknowledgedAt { get; }
|
||||
|
||||
public string? AcknowledgedBy { get; }
|
||||
|
||||
public DateTimeOffset? ResolvedAt { get; }
|
||||
|
||||
public string? ResolvedBy { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record of a single escalation attempt.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationAttempt
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationAttempt(
|
||||
int level,
|
||||
int iteration,
|
||||
DateTimeOffset timestamp,
|
||||
ImmutableArray<string> notifiedTargets,
|
||||
bool success,
|
||||
string? failureReason = null)
|
||||
{
|
||||
Level = level >= 0 ? level : 0;
|
||||
Iteration = iteration >= 0 ? iteration : 0;
|
||||
Timestamp = NotifyValidation.EnsureUtc(timestamp);
|
||||
NotifiedTargets = notifiedTargets.IsDefault ? ImmutableArray<string>.Empty : notifiedTargets;
|
||||
Success = success;
|
||||
FailureReason = NotifyValidation.TrimToNull(failureReason);
|
||||
}
|
||||
|
||||
public int Level { get; }
|
||||
|
||||
public int Iteration { get; }
|
||||
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public ImmutableArray<string> NotifiedTargets { get; }
|
||||
|
||||
public bool Success { get; }
|
||||
|
||||
public string? FailureReason { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of an escalation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyEscalationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Escalation is active and being processed.
|
||||
/// </summary>
|
||||
Active,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was acknowledged.
|
||||
/// </summary>
|
||||
Acknowledged,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was resolved.
|
||||
/// </summary>
|
||||
Resolved,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation exhausted all levels and repeats.
|
||||
/// </summary>
|
||||
Exhausted,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was manually suppressed.
|
||||
/// </summary>
|
||||
Suppressed
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Escalation policy defining how incidents are escalated through multiple levels.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationPolicy
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationPolicy(
|
||||
string policyId,
|
||||
string tenantId,
|
||||
string name,
|
||||
ImmutableArray<NotifyEscalationLevel> levels,
|
||||
bool enabled = true,
|
||||
bool repeatEnabled = false,
|
||||
int? repeatCount = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
PolicyId = NotifyValidation.EnsureNotNullOrWhiteSpace(policyId, nameof(policyId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Levels = NormalizeLevels(levels);
|
||||
|
||||
if (Levels.IsDefaultOrEmpty)
|
||||
{
|
||||
throw new ArgumentException("At least one escalation level is required.", nameof(levels));
|
||||
}
|
||||
|
||||
Enabled = enabled;
|
||||
RepeatEnabled = repeatEnabled;
|
||||
RepeatCount = repeatCount is > 0 ? repeatCount : null;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyEscalationPolicy Create(
|
||||
string policyId,
|
||||
string tenantId,
|
||||
string name,
|
||||
IEnumerable<NotifyEscalationLevel>? levels,
|
||||
bool enabled = true,
|
||||
bool repeatEnabled = false,
|
||||
int? repeatCount = null,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyEscalationPolicy(
|
||||
policyId,
|
||||
tenantId,
|
||||
name,
|
||||
ToImmutableArray(levels),
|
||||
enabled,
|
||||
repeatEnabled,
|
||||
repeatCount,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string PolicyId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of escalation levels.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationLevel> Levels { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to repeat the escalation cycle after reaching the last level.
|
||||
/// </summary>
|
||||
public bool RepeatEnabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of times to repeat the escalation cycle.
|
||||
/// </summary>
|
||||
public int? RepeatCount { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableArray<NotifyEscalationLevel> NormalizeLevels(ImmutableArray<NotifyEscalationLevel> levels)
|
||||
{
|
||||
if (levels.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<NotifyEscalationLevel>.Empty;
|
||||
}
|
||||
|
||||
return levels
|
||||
.Where(static l => l is not null)
|
||||
.OrderBy(static l => l.Order)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyEscalationLevel> ToImmutableArray(IEnumerable<NotifyEscalationLevel>? levels)
|
||||
=> levels is null ? ImmutableArray<NotifyEscalationLevel>.Empty : levels.ToImmutableArray();
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single level in an escalation policy.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationLevel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationLevel(
|
||||
int order,
|
||||
TimeSpan escalateAfter,
|
||||
ImmutableArray<NotifyEscalationTarget> targets,
|
||||
string? name = null,
|
||||
bool notifyAll = true)
|
||||
{
|
||||
Order = order >= 0 ? order : 0;
|
||||
EscalateAfter = escalateAfter > TimeSpan.Zero ? escalateAfter : TimeSpan.FromMinutes(15);
|
||||
Targets = NormalizeTargets(targets);
|
||||
Name = NotifyValidation.TrimToNull(name);
|
||||
NotifyAll = notifyAll;
|
||||
}
|
||||
|
||||
public static NotifyEscalationLevel Create(
|
||||
int order,
|
||||
TimeSpan escalateAfter,
|
||||
IEnumerable<NotifyEscalationTarget>? targets,
|
||||
string? name = null,
|
||||
bool notifyAll = true)
|
||||
{
|
||||
return new NotifyEscalationLevel(
|
||||
order,
|
||||
escalateAfter,
|
||||
ToImmutableArray(targets),
|
||||
name,
|
||||
notifyAll);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order of this level in the escalation chain (0-based).
|
||||
/// </summary>
|
||||
public int Order { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Time to wait before escalating to this level.
|
||||
/// </summary>
|
||||
public TimeSpan EscalateAfter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Targets to notify at this level.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationTarget> Targets { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional name for this level (e.g., "Primary", "Secondary", "Management").
|
||||
/// </summary>
|
||||
public string? Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to notify all targets at this level, or just the first available.
|
||||
/// </summary>
|
||||
public bool NotifyAll { get; }
|
||||
|
||||
private static ImmutableArray<NotifyEscalationTarget> NormalizeTargets(ImmutableArray<NotifyEscalationTarget> targets)
|
||||
{
|
||||
if (targets.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<NotifyEscalationTarget>.Empty;
|
||||
}
|
||||
|
||||
return targets
|
||||
.Where(static t => t is not null)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyEscalationTarget> ToImmutableArray(IEnumerable<NotifyEscalationTarget>? targets)
|
||||
=> targets is null ? ImmutableArray<NotifyEscalationTarget>.Empty : targets.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Target to notify during escalation.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationTarget
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationTarget(
|
||||
NotifyEscalationTargetType type,
|
||||
string targetId,
|
||||
string? channelOverride = null)
|
||||
{
|
||||
Type = type;
|
||||
TargetId = NotifyValidation.EnsureNotNullOrWhiteSpace(targetId, nameof(targetId));
|
||||
ChannelOverride = NotifyValidation.TrimToNull(channelOverride);
|
||||
}
|
||||
|
||||
public static NotifyEscalationTarget Create(
|
||||
NotifyEscalationTargetType type,
|
||||
string targetId,
|
||||
string? channelOverride = null)
|
||||
{
|
||||
return new NotifyEscalationTarget(type, targetId, channelOverride);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of target (user, on-call schedule, channel, external service).
|
||||
/// </summary>
|
||||
public NotifyEscalationTargetType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the target (user ID, schedule ID, channel ID, or external service ID).
|
||||
/// </summary>
|
||||
public string TargetId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel override for this target.
|
||||
/// </summary>
|
||||
public string? ChannelOverride { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of escalation target.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyEscalationTargetType
|
||||
{
|
||||
/// <summary>
|
||||
/// A specific user.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// An on-call schedule (resolves to current on-call user).
|
||||
/// </summary>
|
||||
OnCallSchedule,
|
||||
|
||||
/// <summary>
|
||||
/// A notification channel directly.
|
||||
/// </summary>
|
||||
Channel,
|
||||
|
||||
/// <summary>
|
||||
/// External service (PagerDuty, OpsGenie, etc.).
|
||||
/// </summary>
|
||||
ExternalService,
|
||||
|
||||
/// <summary>
|
||||
/// In-app inbox notification.
|
||||
/// </summary>
|
||||
InAppInbox
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the current state of an escalation for an incident.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationState
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationState(
|
||||
string stateId,
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
int currentLevel,
|
||||
int repeatIteration,
|
||||
NotifyEscalationStatus status,
|
||||
ImmutableArray<NotifyEscalationAttempt> attempts,
|
||||
DateTimeOffset? nextEscalationAt = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
DateTimeOffset? acknowledgedAt = null,
|
||||
string? acknowledgedBy = null,
|
||||
DateTimeOffset? resolvedAt = null,
|
||||
string? resolvedBy = null)
|
||||
{
|
||||
StateId = NotifyValidation.EnsureNotNullOrWhiteSpace(stateId, nameof(stateId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
IncidentId = NotifyValidation.EnsureNotNullOrWhiteSpace(incidentId, nameof(incidentId));
|
||||
PolicyId = NotifyValidation.EnsureNotNullOrWhiteSpace(policyId, nameof(policyId));
|
||||
CurrentLevel = currentLevel >= 0 ? currentLevel : 0;
|
||||
RepeatIteration = repeatIteration >= 0 ? repeatIteration : 0;
|
||||
Status = status;
|
||||
Attempts = attempts.IsDefault ? ImmutableArray<NotifyEscalationAttempt>.Empty : attempts;
|
||||
NextEscalationAt = NotifyValidation.EnsureUtc(nextEscalationAt);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
AcknowledgedAt = NotifyValidation.EnsureUtc(acknowledgedAt);
|
||||
AcknowledgedBy = NotifyValidation.TrimToNull(acknowledgedBy);
|
||||
ResolvedAt = NotifyValidation.EnsureUtc(resolvedAt);
|
||||
ResolvedBy = NotifyValidation.TrimToNull(resolvedBy);
|
||||
}
|
||||
|
||||
public static NotifyEscalationState Create(
|
||||
string stateId,
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
int currentLevel = 0,
|
||||
int repeatIteration = 0,
|
||||
NotifyEscalationStatus status = NotifyEscalationStatus.Active,
|
||||
IEnumerable<NotifyEscalationAttempt>? attempts = null,
|
||||
DateTimeOffset? nextEscalationAt = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
DateTimeOffset? acknowledgedAt = null,
|
||||
string? acknowledgedBy = null,
|
||||
DateTimeOffset? resolvedAt = null,
|
||||
string? resolvedBy = null)
|
||||
{
|
||||
return new NotifyEscalationState(
|
||||
stateId,
|
||||
tenantId,
|
||||
incidentId,
|
||||
policyId,
|
||||
currentLevel,
|
||||
repeatIteration,
|
||||
status,
|
||||
attempts?.ToImmutableArray() ?? ImmutableArray<NotifyEscalationAttempt>.Empty,
|
||||
nextEscalationAt,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
acknowledgedAt,
|
||||
acknowledgedBy,
|
||||
resolvedAt,
|
||||
resolvedBy);
|
||||
}
|
||||
|
||||
public string StateId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string IncidentId { get; }
|
||||
|
||||
public string PolicyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current escalation level (0-based index).
|
||||
/// </summary>
|
||||
public int CurrentLevel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current repeat iteration (0 = first pass through levels).
|
||||
/// </summary>
|
||||
public int RepeatIteration { get; }
|
||||
|
||||
public NotifyEscalationStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// History of escalation attempts.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationAttempt> Attempts { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the next escalation will occur.
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextEscalationAt { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
public DateTimeOffset? AcknowledgedAt { get; }
|
||||
|
||||
public string? AcknowledgedBy { get; }
|
||||
|
||||
public DateTimeOffset? ResolvedAt { get; }
|
||||
|
||||
public string? ResolvedBy { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record of a single escalation attempt.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationAttempt
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationAttempt(
|
||||
int level,
|
||||
int iteration,
|
||||
DateTimeOffset timestamp,
|
||||
ImmutableArray<string> notifiedTargets,
|
||||
bool success,
|
||||
string? failureReason = null)
|
||||
{
|
||||
Level = level >= 0 ? level : 0;
|
||||
Iteration = iteration >= 0 ? iteration : 0;
|
||||
Timestamp = NotifyValidation.EnsureUtc(timestamp);
|
||||
NotifiedTargets = notifiedTargets.IsDefault ? ImmutableArray<string>.Empty : notifiedTargets;
|
||||
Success = success;
|
||||
FailureReason = NotifyValidation.TrimToNull(failureReason);
|
||||
}
|
||||
|
||||
public int Level { get; }
|
||||
|
||||
public int Iteration { get; }
|
||||
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public ImmutableArray<string> NotifiedTargets { get; }
|
||||
|
||||
public bool Success { get; }
|
||||
|
||||
public string? FailureReason { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of an escalation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyEscalationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Escalation is active and being processed.
|
||||
/// </summary>
|
||||
Active,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was acknowledged.
|
||||
/// </summary>
|
||||
Acknowledged,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was resolved.
|
||||
/// </summary>
|
||||
Resolved,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation exhausted all levels and repeats.
|
||||
/// </summary>
|
||||
Exhausted,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was manually suppressed.
|
||||
/// </summary>
|
||||
Suppressed
|
||||
}
|
||||
|
||||
@@ -1,168 +1,168 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical platform event envelope consumed by Notify.
|
||||
/// </summary>
|
||||
public sealed record NotifyEvent
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEvent(
|
||||
Guid eventId,
|
||||
string kind,
|
||||
string tenant,
|
||||
DateTimeOffset ts,
|
||||
JsonNode? payload,
|
||||
NotifyEventScope? scope = null,
|
||||
string? version = null,
|
||||
string? actor = null,
|
||||
ImmutableDictionary<string, string>? attributes = null)
|
||||
{
|
||||
EventId = eventId;
|
||||
Kind = NotifyValidation.EnsureNotNullOrWhiteSpace(kind, nameof(kind)).ToLowerInvariant();
|
||||
Tenant = NotifyValidation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant));
|
||||
Ts = NotifyValidation.EnsureUtc(ts);
|
||||
Payload = NotifyValidation.NormalizeJsonNode(payload);
|
||||
Scope = scope;
|
||||
Version = NotifyValidation.TrimToNull(version);
|
||||
Actor = NotifyValidation.TrimToNull(actor);
|
||||
Attributes = NotifyValidation.NormalizeStringDictionary(attributes);
|
||||
}
|
||||
|
||||
public static NotifyEvent Create(
|
||||
Guid eventId,
|
||||
string kind,
|
||||
string tenant,
|
||||
DateTimeOffset ts,
|
||||
JsonNode? payload,
|
||||
NotifyEventScope? scope = null,
|
||||
string? version = null,
|
||||
string? actor = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? attributes = null)
|
||||
{
|
||||
return new NotifyEvent(
|
||||
eventId,
|
||||
kind,
|
||||
tenant,
|
||||
ts,
|
||||
payload,
|
||||
scope,
|
||||
version,
|
||||
actor,
|
||||
ToImmutableDictionary(attributes));
|
||||
}
|
||||
|
||||
public Guid EventId { get; }
|
||||
|
||||
public string Kind { get; }
|
||||
|
||||
public string Tenant { get; }
|
||||
|
||||
public DateTimeOffset Ts { get; }
|
||||
|
||||
public JsonNode? Payload { get; }
|
||||
|
||||
public NotifyEventScope? Scope { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
|
||||
public string? Actor { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Attributes { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional scope block describing where the event originated (namespace/repo/digest/etc.).
|
||||
/// </summary>
|
||||
public sealed record NotifyEventScope
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEventScope(
|
||||
string? @namespace = null,
|
||||
string? repo = null,
|
||||
string? digest = null,
|
||||
string? component = null,
|
||||
string? image = null,
|
||||
ImmutableDictionary<string, string>? labels = null,
|
||||
ImmutableDictionary<string, string>? attributes = null)
|
||||
{
|
||||
Namespace = NotifyValidation.TrimToNull(@namespace);
|
||||
Repo = NotifyValidation.TrimToNull(repo);
|
||||
Digest = NotifyValidation.TrimToNull(digest);
|
||||
Component = NotifyValidation.TrimToNull(component);
|
||||
Image = NotifyValidation.TrimToNull(image);
|
||||
Labels = NotifyValidation.NormalizeStringDictionary(labels);
|
||||
Attributes = NotifyValidation.NormalizeStringDictionary(attributes);
|
||||
}
|
||||
|
||||
public static NotifyEventScope Create(
|
||||
string? @namespace = null,
|
||||
string? repo = null,
|
||||
string? digest = null,
|
||||
string? component = null,
|
||||
string? image = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? labels = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? attributes = null)
|
||||
{
|
||||
return new NotifyEventScope(
|
||||
@namespace,
|
||||
repo,
|
||||
digest,
|
||||
component,
|
||||
image,
|
||||
ToImmutableDictionary(labels),
|
||||
ToImmutableDictionary(attributes));
|
||||
}
|
||||
|
||||
public string? Namespace { get; }
|
||||
|
||||
public string? Repo { get; }
|
||||
|
||||
public string? Digest { get; }
|
||||
|
||||
public string? Component { get; }
|
||||
|
||||
public string? Image { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Labels { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Attributes { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical platform event envelope consumed by Notify.
|
||||
/// </summary>
|
||||
public sealed record NotifyEvent
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEvent(
|
||||
Guid eventId,
|
||||
string kind,
|
||||
string tenant,
|
||||
DateTimeOffset ts,
|
||||
JsonNode? payload,
|
||||
NotifyEventScope? scope = null,
|
||||
string? version = null,
|
||||
string? actor = null,
|
||||
ImmutableDictionary<string, string>? attributes = null)
|
||||
{
|
||||
EventId = eventId;
|
||||
Kind = NotifyValidation.EnsureNotNullOrWhiteSpace(kind, nameof(kind)).ToLowerInvariant();
|
||||
Tenant = NotifyValidation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant));
|
||||
Ts = NotifyValidation.EnsureUtc(ts);
|
||||
Payload = NotifyValidation.NormalizeJsonNode(payload);
|
||||
Scope = scope;
|
||||
Version = NotifyValidation.TrimToNull(version);
|
||||
Actor = NotifyValidation.TrimToNull(actor);
|
||||
Attributes = NotifyValidation.NormalizeStringDictionary(attributes);
|
||||
}
|
||||
|
||||
public static NotifyEvent Create(
|
||||
Guid eventId,
|
||||
string kind,
|
||||
string tenant,
|
||||
DateTimeOffset ts,
|
||||
JsonNode? payload,
|
||||
NotifyEventScope? scope = null,
|
||||
string? version = null,
|
||||
string? actor = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? attributes = null)
|
||||
{
|
||||
return new NotifyEvent(
|
||||
eventId,
|
||||
kind,
|
||||
tenant,
|
||||
ts,
|
||||
payload,
|
||||
scope,
|
||||
version,
|
||||
actor,
|
||||
ToImmutableDictionary(attributes));
|
||||
}
|
||||
|
||||
public Guid EventId { get; }
|
||||
|
||||
public string Kind { get; }
|
||||
|
||||
public string Tenant { get; }
|
||||
|
||||
public DateTimeOffset Ts { get; }
|
||||
|
||||
public JsonNode? Payload { get; }
|
||||
|
||||
public NotifyEventScope? Scope { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
|
||||
public string? Actor { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Attributes { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional scope block describing where the event originated (namespace/repo/digest/etc.).
|
||||
/// </summary>
|
||||
public sealed record NotifyEventScope
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEventScope(
|
||||
string? @namespace = null,
|
||||
string? repo = null,
|
||||
string? digest = null,
|
||||
string? component = null,
|
||||
string? image = null,
|
||||
ImmutableDictionary<string, string>? labels = null,
|
||||
ImmutableDictionary<string, string>? attributes = null)
|
||||
{
|
||||
Namespace = NotifyValidation.TrimToNull(@namespace);
|
||||
Repo = NotifyValidation.TrimToNull(repo);
|
||||
Digest = NotifyValidation.TrimToNull(digest);
|
||||
Component = NotifyValidation.TrimToNull(component);
|
||||
Image = NotifyValidation.TrimToNull(image);
|
||||
Labels = NotifyValidation.NormalizeStringDictionary(labels);
|
||||
Attributes = NotifyValidation.NormalizeStringDictionary(attributes);
|
||||
}
|
||||
|
||||
public static NotifyEventScope Create(
|
||||
string? @namespace = null,
|
||||
string? repo = null,
|
||||
string? digest = null,
|
||||
string? component = null,
|
||||
string? image = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? labels = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? attributes = null)
|
||||
{
|
||||
return new NotifyEventScope(
|
||||
@namespace,
|
||||
repo,
|
||||
digest,
|
||||
component,
|
||||
image,
|
||||
ToImmutableDictionary(labels),
|
||||
ToImmutableDictionary(attributes));
|
||||
}
|
||||
|
||||
public string? Namespace { get; }
|
||||
|
||||
public string? Repo { get; }
|
||||
|
||||
public string? Digest { get; }
|
||||
|
||||
public string? Component { get; }
|
||||
|
||||
public string? Image { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Labels { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Attributes { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Known platform event kind identifiers consumed by Notify.
|
||||
/// </summary>
|
||||
public static class NotifyEventKinds
|
||||
{
|
||||
public const string ScannerReportReady = "scanner.report.ready";
|
||||
public const string ScannerScanCompleted = "scanner.scan.completed";
|
||||
public const string SchedulerRescanDelta = "scheduler.rescan.delta";
|
||||
public const string AttestorLogged = "attestor.logged";
|
||||
public const string ZastavaAdmission = "zastava.admission";
|
||||
public const string ConselierExportCompleted = "conselier.export.completed";
|
||||
public const string ExcitorExportCompleted = "excitor.export.completed";
|
||||
public const string AirgapTimeDrift = "airgap.time.drift";
|
||||
public const string AirgapBundleImport = "airgap.bundle.import";
|
||||
public const string AirgapPortableExportCompleted = "airgap.portable.export.completed";
|
||||
}
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Known platform event kind identifiers consumed by Notify.
|
||||
/// </summary>
|
||||
public static class NotifyEventKinds
|
||||
{
|
||||
public const string ScannerReportReady = "scanner.report.ready";
|
||||
public const string ScannerScanCompleted = "scanner.scan.completed";
|
||||
public const string SchedulerRescanDelta = "scheduler.rescan.delta";
|
||||
public const string AttestorLogged = "attestor.logged";
|
||||
public const string ZastavaAdmission = "zastava.admission";
|
||||
public const string ConselierExportCompleted = "conselier.export.completed";
|
||||
public const string ExcitorExportCompleted = "excitor.export.completed";
|
||||
public const string AirgapTimeDrift = "airgap.time.drift";
|
||||
public const string AirgapBundleImport = "airgap.bundle.import";
|
||||
public const string AirgapPortableExportCompleted = "airgap.portable.export.completed";
|
||||
}
|
||||
|
||||
@@ -1,233 +1,233 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A localization bundle containing translated strings for a specific locale.
|
||||
/// </summary>
|
||||
public sealed record NotifyLocalizationBundle
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyLocalizationBundle(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string locale,
|
||||
string bundleKey,
|
||||
ImmutableDictionary<string, string> strings,
|
||||
bool isDefault = false,
|
||||
string? parentLocale = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
BundleId = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleId, nameof(bundleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Locale = NotifyValidation.EnsureNotNullOrWhiteSpace(locale, nameof(locale)).ToLowerInvariant();
|
||||
BundleKey = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleKey, nameof(bundleKey));
|
||||
Strings = strings;
|
||||
IsDefault = isDefault;
|
||||
ParentLocale = NormalizeParentLocale(parentLocale, Locale);
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyLocalizationBundle Create(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string locale,
|
||||
string bundleKey,
|
||||
IEnumerable<KeyValuePair<string, string>>? strings = null,
|
||||
bool isDefault = false,
|
||||
string? parentLocale = null,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyLocalizationBundle(
|
||||
bundleId,
|
||||
tenantId,
|
||||
locale,
|
||||
bundleKey,
|
||||
ToImmutableDictionary(strings) ?? ImmutableDictionary<string, string>.Empty,
|
||||
isDefault,
|
||||
parentLocale,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this bundle.
|
||||
/// </summary>
|
||||
public string BundleId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID this bundle belongs to.
|
||||
/// </summary>
|
||||
public string TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Locale code (e.g., "en-us", "fr-fr", "ja-jp").
|
||||
/// </summary>
|
||||
public string Locale { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle key for grouping related bundles (e.g., "notifications", "email-subjects").
|
||||
/// </summary>
|
||||
public string BundleKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary of string key to translated value.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Strings { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the default/fallback bundle for the bundle key.
|
||||
/// </summary>
|
||||
public bool IsDefault { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent locale for fallback chain (e.g., "en" for "en-us").
|
||||
/// Automatically computed if not specified.
|
||||
/// </summary>
|
||||
public string? ParentLocale { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a localized string by key.
|
||||
/// </summary>
|
||||
public string? GetString(string key)
|
||||
{
|
||||
return Strings.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a localized string by key with a default fallback.
|
||||
/// </summary>
|
||||
public string GetString(string key, string defaultValue)
|
||||
{
|
||||
return Strings.TryGetValue(key, out var value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
private static string? NormalizeParentLocale(string? parentLocale, string locale)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(parentLocale))
|
||||
{
|
||||
return parentLocale.ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Auto-compute parent locale from locale
|
||||
// e.g., "en-us" -> "en", "pt-br" -> "pt"
|
||||
var dashIndex = locale.IndexOf('-');
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
return locale[..dashIndex];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving localized strings with fallback chain support.
|
||||
/// </summary>
|
||||
public interface ILocalizationResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a localized string using the fallback chain.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="bundleKey">The bundle key.</param>
|
||||
/// <param name="stringKey">The string key within the bundle.</param>
|
||||
/// <param name="locale">The preferred locale.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The resolved string or null if not found.</returns>
|
||||
Task<LocalizedString?> ResolveAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
string stringKey,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves multiple strings at once for efficiency.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
IEnumerable<string> stringKeys,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a localization resolution.
|
||||
/// </summary>
|
||||
public sealed record LocalizedString
|
||||
{
|
||||
/// <summary>
|
||||
/// The resolved string value.
|
||||
/// </summary>
|
||||
public required string Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The locale that provided the value.
|
||||
/// </summary>
|
||||
public required string ResolvedLocale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The originally requested locale.
|
||||
/// </summary>
|
||||
public required string RequestedLocale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether fallback was used.
|
||||
/// </summary>
|
||||
public bool UsedFallback => !ResolvedLocale.Equals(RequestedLocale, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The fallback chain that was traversed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> FallbackChain { get; init; } = [];
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A localization bundle containing translated strings for a specific locale.
|
||||
/// </summary>
|
||||
public sealed record NotifyLocalizationBundle
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyLocalizationBundle(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string locale,
|
||||
string bundleKey,
|
||||
ImmutableDictionary<string, string> strings,
|
||||
bool isDefault = false,
|
||||
string? parentLocale = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
BundleId = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleId, nameof(bundleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Locale = NotifyValidation.EnsureNotNullOrWhiteSpace(locale, nameof(locale)).ToLowerInvariant();
|
||||
BundleKey = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleKey, nameof(bundleKey));
|
||||
Strings = strings;
|
||||
IsDefault = isDefault;
|
||||
ParentLocale = NormalizeParentLocale(parentLocale, Locale);
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyLocalizationBundle Create(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string locale,
|
||||
string bundleKey,
|
||||
IEnumerable<KeyValuePair<string, string>>? strings = null,
|
||||
bool isDefault = false,
|
||||
string? parentLocale = null,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyLocalizationBundle(
|
||||
bundleId,
|
||||
tenantId,
|
||||
locale,
|
||||
bundleKey,
|
||||
ToImmutableDictionary(strings) ?? ImmutableDictionary<string, string>.Empty,
|
||||
isDefault,
|
||||
parentLocale,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this bundle.
|
||||
/// </summary>
|
||||
public string BundleId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID this bundle belongs to.
|
||||
/// </summary>
|
||||
public string TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Locale code (e.g., "en-us", "fr-fr", "ja-jp").
|
||||
/// </summary>
|
||||
public string Locale { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle key for grouping related bundles (e.g., "notifications", "email-subjects").
|
||||
/// </summary>
|
||||
public string BundleKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary of string key to translated value.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Strings { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the default/fallback bundle for the bundle key.
|
||||
/// </summary>
|
||||
public bool IsDefault { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent locale for fallback chain (e.g., "en" for "en-us").
|
||||
/// Automatically computed if not specified.
|
||||
/// </summary>
|
||||
public string? ParentLocale { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a localized string by key.
|
||||
/// </summary>
|
||||
public string? GetString(string key)
|
||||
{
|
||||
return Strings.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a localized string by key with a default fallback.
|
||||
/// </summary>
|
||||
public string GetString(string key, string defaultValue)
|
||||
{
|
||||
return Strings.TryGetValue(key, out var value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
private static string? NormalizeParentLocale(string? parentLocale, string locale)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(parentLocale))
|
||||
{
|
||||
return parentLocale.ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Auto-compute parent locale from locale
|
||||
// e.g., "en-us" -> "en", "pt-br" -> "pt"
|
||||
var dashIndex = locale.IndexOf('-');
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
return locale[..dashIndex];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving localized strings with fallback chain support.
|
||||
/// </summary>
|
||||
public interface ILocalizationResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a localized string using the fallback chain.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="bundleKey">The bundle key.</param>
|
||||
/// <param name="stringKey">The string key within the bundle.</param>
|
||||
/// <param name="locale">The preferred locale.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The resolved string or null if not found.</returns>
|
||||
Task<LocalizedString?> ResolveAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
string stringKey,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves multiple strings at once for efficiency.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
IEnumerable<string> stringKeys,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a localization resolution.
|
||||
/// </summary>
|
||||
public sealed record LocalizedString
|
||||
{
|
||||
/// <summary>
|
||||
/// The resolved string value.
|
||||
/// </summary>
|
||||
public required string Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The locale that provided the value.
|
||||
/// </summary>
|
||||
public required string ResolvedLocale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The originally requested locale.
|
||||
/// </summary>
|
||||
public required string RequestedLocale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether fallback was used.
|
||||
/// </summary>
|
||||
public bool UsedFallback => !ResolvedLocale.Equals(RequestedLocale, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The fallback chain that was traversed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> FallbackChain { get; init; } = [];
|
||||
}
|
||||
|
||||
@@ -1,494 +1,494 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// On-call schedule defining who is on-call at any given time.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallSchedule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallSchedule(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string timeZone,
|
||||
ImmutableArray<NotifyOnCallLayer> layers,
|
||||
ImmutableArray<NotifyOnCallOverride> overrides,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone));
|
||||
Layers = layers.IsDefault ? ImmutableArray<NotifyOnCallLayer>.Empty : layers;
|
||||
Overrides = overrides.IsDefault ? ImmutableArray<NotifyOnCallOverride>.Empty : overrides;
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyOnCallSchedule Create(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string timeZone,
|
||||
IEnumerable<NotifyOnCallLayer>? layers = null,
|
||||
IEnumerable<NotifyOnCallOverride>? overrides = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyOnCallSchedule(
|
||||
scheduleId,
|
||||
tenantId,
|
||||
name,
|
||||
timeZone,
|
||||
layers?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallLayer>.Empty,
|
||||
overrides?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallOverride>.Empty,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IANA time zone for the schedule (e.g., "America/New_York").
|
||||
/// </summary>
|
||||
public string TimeZone { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rotation layers that make up this schedule.
|
||||
/// Multiple layers are combined to determine final on-call.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallLayer> Layers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Temporary overrides (e.g., vacation coverage).
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallOverride> Overrides { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A layer in an on-call schedule representing a rotation.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallLayer
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallLayer(
|
||||
string layerId,
|
||||
string name,
|
||||
int priority,
|
||||
NotifyRotationType rotationType,
|
||||
TimeSpan rotationInterval,
|
||||
DateTimeOffset rotationStartsAt,
|
||||
ImmutableArray<NotifyOnCallParticipant> participants,
|
||||
NotifyOnCallRestriction? restrictions = null)
|
||||
{
|
||||
LayerId = NotifyValidation.EnsureNotNullOrWhiteSpace(layerId, nameof(layerId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Priority = priority;
|
||||
RotationType = rotationType;
|
||||
RotationInterval = rotationInterval > TimeSpan.Zero ? rotationInterval : TimeSpan.FromDays(7);
|
||||
RotationStartsAt = NotifyValidation.EnsureUtc(rotationStartsAt);
|
||||
Participants = participants.IsDefault ? ImmutableArray<NotifyOnCallParticipant>.Empty : participants;
|
||||
Restrictions = restrictions;
|
||||
}
|
||||
|
||||
public static NotifyOnCallLayer Create(
|
||||
string layerId,
|
||||
string name,
|
||||
int priority,
|
||||
NotifyRotationType rotationType,
|
||||
TimeSpan rotationInterval,
|
||||
DateTimeOffset rotationStartsAt,
|
||||
IEnumerable<NotifyOnCallParticipant>? participants = null,
|
||||
NotifyOnCallRestriction? restrictions = null)
|
||||
{
|
||||
return new NotifyOnCallLayer(
|
||||
layerId,
|
||||
name,
|
||||
priority,
|
||||
rotationType,
|
||||
rotationInterval,
|
||||
rotationStartsAt,
|
||||
participants?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallParticipant>.Empty,
|
||||
restrictions);
|
||||
}
|
||||
|
||||
public string LayerId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Higher priority layers take precedence when determining who is on-call.
|
||||
/// </summary>
|
||||
public int Priority { get; }
|
||||
|
||||
public NotifyRotationType RotationType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of each rotation (e.g., 1 week).
|
||||
/// </summary>
|
||||
public TimeSpan RotationInterval { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the rotation schedule started.
|
||||
/// </summary>
|
||||
public DateTimeOffset RotationStartsAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Participants in the rotation.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallParticipant> Participants { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional time restrictions for when this layer is active.
|
||||
/// </summary>
|
||||
public NotifyOnCallRestriction? Restrictions { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Participant in an on-call rotation.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallParticipant
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallParticipant(
|
||||
string userId,
|
||||
string? name = null,
|
||||
string? email = null,
|
||||
string? phone = null,
|
||||
ImmutableArray<NotifyContactMethod> contactMethods = default)
|
||||
{
|
||||
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
|
||||
Name = NotifyValidation.TrimToNull(name);
|
||||
Email = NotifyValidation.TrimToNull(email);
|
||||
Phone = NotifyValidation.TrimToNull(phone);
|
||||
ContactMethods = contactMethods.IsDefault ? ImmutableArray<NotifyContactMethod>.Empty : contactMethods;
|
||||
}
|
||||
|
||||
public static NotifyOnCallParticipant Create(
|
||||
string userId,
|
||||
string? name = null,
|
||||
string? email = null,
|
||||
string? phone = null,
|
||||
IEnumerable<NotifyContactMethod>? contactMethods = null)
|
||||
{
|
||||
return new NotifyOnCallParticipant(
|
||||
userId,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
contactMethods?.ToImmutableArray() ?? ImmutableArray<NotifyContactMethod>.Empty);
|
||||
}
|
||||
|
||||
public string UserId { get; }
|
||||
|
||||
public string? Name { get; }
|
||||
|
||||
public string? Email { get; }
|
||||
|
||||
public string? Phone { get; }
|
||||
|
||||
public ImmutableArray<NotifyContactMethod> ContactMethods { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact method for a participant.
|
||||
/// </summary>
|
||||
public sealed record NotifyContactMethod
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyContactMethod(
|
||||
NotifyContactMethodType type,
|
||||
string address,
|
||||
int priority = 0,
|
||||
bool enabled = true)
|
||||
{
|
||||
Type = type;
|
||||
Address = NotifyValidation.EnsureNotNullOrWhiteSpace(address, nameof(address));
|
||||
Priority = priority;
|
||||
Enabled = enabled;
|
||||
}
|
||||
|
||||
public NotifyContactMethodType Type { get; }
|
||||
|
||||
public string Address { get; }
|
||||
|
||||
public int Priority { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of contact method.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyContactMethodType
|
||||
{
|
||||
Email,
|
||||
Sms,
|
||||
Phone,
|
||||
Slack,
|
||||
Teams,
|
||||
Webhook,
|
||||
InAppInbox,
|
||||
PagerDuty,
|
||||
OpsGenie
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of rotation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyRotationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Daily rotation.
|
||||
/// </summary>
|
||||
Daily,
|
||||
|
||||
/// <summary>
|
||||
/// Weekly rotation.
|
||||
/// </summary>
|
||||
Weekly,
|
||||
|
||||
/// <summary>
|
||||
/// Custom interval rotation.
|
||||
/// </summary>
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time restrictions for when an on-call layer is active.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallRestriction
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallRestriction(
|
||||
NotifyRestrictionType type,
|
||||
ImmutableArray<NotifyTimeRange> timeRanges)
|
||||
{
|
||||
Type = type;
|
||||
TimeRanges = timeRanges.IsDefault ? ImmutableArray<NotifyTimeRange>.Empty : timeRanges;
|
||||
}
|
||||
|
||||
public static NotifyOnCallRestriction Create(
|
||||
NotifyRestrictionType type,
|
||||
IEnumerable<NotifyTimeRange>? timeRanges = null)
|
||||
{
|
||||
return new NotifyOnCallRestriction(
|
||||
type,
|
||||
timeRanges?.ToImmutableArray() ?? ImmutableArray<NotifyTimeRange>.Empty);
|
||||
}
|
||||
|
||||
public NotifyRestrictionType Type { get; }
|
||||
|
||||
public ImmutableArray<NotifyTimeRange> TimeRanges { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of restriction.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyRestrictionType
|
||||
{
|
||||
/// <summary>
|
||||
/// Restrictions apply daily.
|
||||
/// </summary>
|
||||
DailyRestriction,
|
||||
|
||||
/// <summary>
|
||||
/// Restrictions apply weekly on specific days.
|
||||
/// </summary>
|
||||
WeeklyRestriction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A time range for restrictions.
|
||||
/// </summary>
|
||||
public sealed record NotifyTimeRange
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyTimeRange(
|
||||
DayOfWeek? dayOfWeek,
|
||||
TimeOnly startTime,
|
||||
TimeOnly endTime)
|
||||
{
|
||||
DayOfWeek = dayOfWeek;
|
||||
StartTime = startTime;
|
||||
EndTime = endTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Day of week (null for daily restrictions).
|
||||
/// </summary>
|
||||
public DayOfWeek? DayOfWeek { get; }
|
||||
|
||||
public TimeOnly StartTime { get; }
|
||||
|
||||
public TimeOnly EndTime { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Temporary override for an on-call schedule.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallOverride
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallOverride(
|
||||
string overrideId,
|
||||
string userId,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId));
|
||||
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
|
||||
StartsAt = NotifyValidation.EnsureUtc(startsAt);
|
||||
EndsAt = NotifyValidation.EnsureUtc(endsAt);
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
|
||||
if (EndsAt <= StartsAt)
|
||||
{
|
||||
throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt));
|
||||
}
|
||||
}
|
||||
|
||||
public static NotifyOnCallOverride Create(
|
||||
string overrideId,
|
||||
string userId,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new NotifyOnCallOverride(
|
||||
overrideId,
|
||||
userId,
|
||||
startsAt,
|
||||
endsAt,
|
||||
reason,
|
||||
createdBy,
|
||||
createdAt);
|
||||
}
|
||||
|
||||
public string OverrideId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// User who will be on-call during this override.
|
||||
/// </summary>
|
||||
public string UserId { get; }
|
||||
|
||||
public DateTimeOffset StartsAt { get; }
|
||||
|
||||
public DateTimeOffset EndsAt { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the override is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> timestamp >= StartsAt && timestamp < EndsAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of resolving who is currently on-call.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallResolution
|
||||
{
|
||||
public NotifyOnCallResolution(
|
||||
string scheduleId,
|
||||
DateTimeOffset evaluatedAt,
|
||||
ImmutableArray<NotifyOnCallParticipant> onCallUsers,
|
||||
string? sourceLayer = null,
|
||||
string? sourceOverride = null)
|
||||
{
|
||||
ScheduleId = scheduleId;
|
||||
EvaluatedAt = evaluatedAt;
|
||||
OnCallUsers = onCallUsers.IsDefault ? ImmutableArray<NotifyOnCallParticipant>.Empty : onCallUsers;
|
||||
SourceLayer = sourceLayer;
|
||||
SourceOverride = sourceOverride;
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public DateTimeOffset EvaluatedAt { get; }
|
||||
|
||||
public ImmutableArray<NotifyOnCallParticipant> OnCallUsers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The layer that provided the on-call user (if from rotation).
|
||||
/// </summary>
|
||||
public string? SourceLayer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The override that provided the on-call user (if from override).
|
||||
/// </summary>
|
||||
public string? SourceOverride { get; }
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// On-call schedule defining who is on-call at any given time.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallSchedule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallSchedule(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string timeZone,
|
||||
ImmutableArray<NotifyOnCallLayer> layers,
|
||||
ImmutableArray<NotifyOnCallOverride> overrides,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone));
|
||||
Layers = layers.IsDefault ? ImmutableArray<NotifyOnCallLayer>.Empty : layers;
|
||||
Overrides = overrides.IsDefault ? ImmutableArray<NotifyOnCallOverride>.Empty : overrides;
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyOnCallSchedule Create(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string timeZone,
|
||||
IEnumerable<NotifyOnCallLayer>? layers = null,
|
||||
IEnumerable<NotifyOnCallOverride>? overrides = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyOnCallSchedule(
|
||||
scheduleId,
|
||||
tenantId,
|
||||
name,
|
||||
timeZone,
|
||||
layers?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallLayer>.Empty,
|
||||
overrides?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallOverride>.Empty,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IANA time zone for the schedule (e.g., "America/New_York").
|
||||
/// </summary>
|
||||
public string TimeZone { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rotation layers that make up this schedule.
|
||||
/// Multiple layers are combined to determine final on-call.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallLayer> Layers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Temporary overrides (e.g., vacation coverage).
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallOverride> Overrides { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A layer in an on-call schedule representing a rotation.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallLayer
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallLayer(
|
||||
string layerId,
|
||||
string name,
|
||||
int priority,
|
||||
NotifyRotationType rotationType,
|
||||
TimeSpan rotationInterval,
|
||||
DateTimeOffset rotationStartsAt,
|
||||
ImmutableArray<NotifyOnCallParticipant> participants,
|
||||
NotifyOnCallRestriction? restrictions = null)
|
||||
{
|
||||
LayerId = NotifyValidation.EnsureNotNullOrWhiteSpace(layerId, nameof(layerId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Priority = priority;
|
||||
RotationType = rotationType;
|
||||
RotationInterval = rotationInterval > TimeSpan.Zero ? rotationInterval : TimeSpan.FromDays(7);
|
||||
RotationStartsAt = NotifyValidation.EnsureUtc(rotationStartsAt);
|
||||
Participants = participants.IsDefault ? ImmutableArray<NotifyOnCallParticipant>.Empty : participants;
|
||||
Restrictions = restrictions;
|
||||
}
|
||||
|
||||
public static NotifyOnCallLayer Create(
|
||||
string layerId,
|
||||
string name,
|
||||
int priority,
|
||||
NotifyRotationType rotationType,
|
||||
TimeSpan rotationInterval,
|
||||
DateTimeOffset rotationStartsAt,
|
||||
IEnumerable<NotifyOnCallParticipant>? participants = null,
|
||||
NotifyOnCallRestriction? restrictions = null)
|
||||
{
|
||||
return new NotifyOnCallLayer(
|
||||
layerId,
|
||||
name,
|
||||
priority,
|
||||
rotationType,
|
||||
rotationInterval,
|
||||
rotationStartsAt,
|
||||
participants?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallParticipant>.Empty,
|
||||
restrictions);
|
||||
}
|
||||
|
||||
public string LayerId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Higher priority layers take precedence when determining who is on-call.
|
||||
/// </summary>
|
||||
public int Priority { get; }
|
||||
|
||||
public NotifyRotationType RotationType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of each rotation (e.g., 1 week).
|
||||
/// </summary>
|
||||
public TimeSpan RotationInterval { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the rotation schedule started.
|
||||
/// </summary>
|
||||
public DateTimeOffset RotationStartsAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Participants in the rotation.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallParticipant> Participants { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional time restrictions for when this layer is active.
|
||||
/// </summary>
|
||||
public NotifyOnCallRestriction? Restrictions { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Participant in an on-call rotation.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallParticipant
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallParticipant(
|
||||
string userId,
|
||||
string? name = null,
|
||||
string? email = null,
|
||||
string? phone = null,
|
||||
ImmutableArray<NotifyContactMethod> contactMethods = default)
|
||||
{
|
||||
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
|
||||
Name = NotifyValidation.TrimToNull(name);
|
||||
Email = NotifyValidation.TrimToNull(email);
|
||||
Phone = NotifyValidation.TrimToNull(phone);
|
||||
ContactMethods = contactMethods.IsDefault ? ImmutableArray<NotifyContactMethod>.Empty : contactMethods;
|
||||
}
|
||||
|
||||
public static NotifyOnCallParticipant Create(
|
||||
string userId,
|
||||
string? name = null,
|
||||
string? email = null,
|
||||
string? phone = null,
|
||||
IEnumerable<NotifyContactMethod>? contactMethods = null)
|
||||
{
|
||||
return new NotifyOnCallParticipant(
|
||||
userId,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
contactMethods?.ToImmutableArray() ?? ImmutableArray<NotifyContactMethod>.Empty);
|
||||
}
|
||||
|
||||
public string UserId { get; }
|
||||
|
||||
public string? Name { get; }
|
||||
|
||||
public string? Email { get; }
|
||||
|
||||
public string? Phone { get; }
|
||||
|
||||
public ImmutableArray<NotifyContactMethod> ContactMethods { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact method for a participant.
|
||||
/// </summary>
|
||||
public sealed record NotifyContactMethod
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyContactMethod(
|
||||
NotifyContactMethodType type,
|
||||
string address,
|
||||
int priority = 0,
|
||||
bool enabled = true)
|
||||
{
|
||||
Type = type;
|
||||
Address = NotifyValidation.EnsureNotNullOrWhiteSpace(address, nameof(address));
|
||||
Priority = priority;
|
||||
Enabled = enabled;
|
||||
}
|
||||
|
||||
public NotifyContactMethodType Type { get; }
|
||||
|
||||
public string Address { get; }
|
||||
|
||||
public int Priority { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of contact method.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyContactMethodType
|
||||
{
|
||||
Email,
|
||||
Sms,
|
||||
Phone,
|
||||
Slack,
|
||||
Teams,
|
||||
Webhook,
|
||||
InAppInbox,
|
||||
PagerDuty,
|
||||
OpsGenie
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of rotation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyRotationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Daily rotation.
|
||||
/// </summary>
|
||||
Daily,
|
||||
|
||||
/// <summary>
|
||||
/// Weekly rotation.
|
||||
/// </summary>
|
||||
Weekly,
|
||||
|
||||
/// <summary>
|
||||
/// Custom interval rotation.
|
||||
/// </summary>
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time restrictions for when an on-call layer is active.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallRestriction
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallRestriction(
|
||||
NotifyRestrictionType type,
|
||||
ImmutableArray<NotifyTimeRange> timeRanges)
|
||||
{
|
||||
Type = type;
|
||||
TimeRanges = timeRanges.IsDefault ? ImmutableArray<NotifyTimeRange>.Empty : timeRanges;
|
||||
}
|
||||
|
||||
public static NotifyOnCallRestriction Create(
|
||||
NotifyRestrictionType type,
|
||||
IEnumerable<NotifyTimeRange>? timeRanges = null)
|
||||
{
|
||||
return new NotifyOnCallRestriction(
|
||||
type,
|
||||
timeRanges?.ToImmutableArray() ?? ImmutableArray<NotifyTimeRange>.Empty);
|
||||
}
|
||||
|
||||
public NotifyRestrictionType Type { get; }
|
||||
|
||||
public ImmutableArray<NotifyTimeRange> TimeRanges { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of restriction.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyRestrictionType
|
||||
{
|
||||
/// <summary>
|
||||
/// Restrictions apply daily.
|
||||
/// </summary>
|
||||
DailyRestriction,
|
||||
|
||||
/// <summary>
|
||||
/// Restrictions apply weekly on specific days.
|
||||
/// </summary>
|
||||
WeeklyRestriction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A time range for restrictions.
|
||||
/// </summary>
|
||||
public sealed record NotifyTimeRange
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyTimeRange(
|
||||
DayOfWeek? dayOfWeek,
|
||||
TimeOnly startTime,
|
||||
TimeOnly endTime)
|
||||
{
|
||||
DayOfWeek = dayOfWeek;
|
||||
StartTime = startTime;
|
||||
EndTime = endTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Day of week (null for daily restrictions).
|
||||
/// </summary>
|
||||
public DayOfWeek? DayOfWeek { get; }
|
||||
|
||||
public TimeOnly StartTime { get; }
|
||||
|
||||
public TimeOnly EndTime { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Temporary override for an on-call schedule.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallOverride
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallOverride(
|
||||
string overrideId,
|
||||
string userId,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId));
|
||||
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
|
||||
StartsAt = NotifyValidation.EnsureUtc(startsAt);
|
||||
EndsAt = NotifyValidation.EnsureUtc(endsAt);
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
|
||||
if (EndsAt <= StartsAt)
|
||||
{
|
||||
throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt));
|
||||
}
|
||||
}
|
||||
|
||||
public static NotifyOnCallOverride Create(
|
||||
string overrideId,
|
||||
string userId,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new NotifyOnCallOverride(
|
||||
overrideId,
|
||||
userId,
|
||||
startsAt,
|
||||
endsAt,
|
||||
reason,
|
||||
createdBy,
|
||||
createdAt);
|
||||
}
|
||||
|
||||
public string OverrideId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// User who will be on-call during this override.
|
||||
/// </summary>
|
||||
public string UserId { get; }
|
||||
|
||||
public DateTimeOffset StartsAt { get; }
|
||||
|
||||
public DateTimeOffset EndsAt { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the override is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> timestamp >= StartsAt && timestamp < EndsAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of resolving who is currently on-call.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallResolution
|
||||
{
|
||||
public NotifyOnCallResolution(
|
||||
string scheduleId,
|
||||
DateTimeOffset evaluatedAt,
|
||||
ImmutableArray<NotifyOnCallParticipant> onCallUsers,
|
||||
string? sourceLayer = null,
|
||||
string? sourceOverride = null)
|
||||
{
|
||||
ScheduleId = scheduleId;
|
||||
EvaluatedAt = evaluatedAt;
|
||||
OnCallUsers = onCallUsers.IsDefault ? ImmutableArray<NotifyOnCallParticipant>.Empty : onCallUsers;
|
||||
SourceLayer = sourceLayer;
|
||||
SourceOverride = sourceOverride;
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public DateTimeOffset EvaluatedAt { get; }
|
||||
|
||||
public ImmutableArray<NotifyOnCallParticipant> OnCallUsers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The layer that provided the on-call user (if from rotation).
|
||||
/// </summary>
|
||||
public string? SourceLayer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The override that provided the on-call user (if from override).
|
||||
/// </summary>
|
||||
public string? SourceOverride { get; }
|
||||
}
|
||||
|
||||
@@ -1,401 +1,401 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Quiet hours schedule configuration for suppressing notifications during specified periods.
|
||||
/// </summary>
|
||||
public sealed record NotifyQuietHoursSchedule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyQuietHoursSchedule(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string cronExpression,
|
||||
TimeSpan duration,
|
||||
string timeZone,
|
||||
string? channelId = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
CronExpression = NotifyValidation.EnsureNotNullOrWhiteSpace(cronExpression, nameof(cronExpression));
|
||||
Duration = duration > TimeSpan.Zero ? duration : TimeSpan.FromHours(8);
|
||||
TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone));
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyQuietHoursSchedule Create(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string cronExpression,
|
||||
TimeSpan duration,
|
||||
string timeZone,
|
||||
string? channelId = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyQuietHoursSchedule(
|
||||
scheduleId,
|
||||
tenantId,
|
||||
name,
|
||||
cronExpression,
|
||||
duration,
|
||||
timeZone,
|
||||
channelId,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Cron expression defining when quiet hours start.
|
||||
/// </summary>
|
||||
public string CronExpression { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the quiet hours window.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IANA time zone for evaluating the cron expression (e.g., "America/New_York").
|
||||
/// </summary>
|
||||
public string TimeZone { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope quiet hours to a specific channel.
|
||||
/// If null, applies to all channels.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maintenance window for planned suppression of notifications.
|
||||
/// </summary>
|
||||
public sealed record NotifyMaintenanceWindow
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyMaintenanceWindow(
|
||||
string windowId,
|
||||
string tenantId,
|
||||
string name,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
bool suppressNotifications = true,
|
||||
string? reason = null,
|
||||
ImmutableArray<string> channelIds = default,
|
||||
ImmutableArray<string> ruleIds = default,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
WindowId = NotifyValidation.EnsureNotNullOrWhiteSpace(windowId, nameof(windowId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
StartsAt = NotifyValidation.EnsureUtc(startsAt);
|
||||
EndsAt = NotifyValidation.EnsureUtc(endsAt);
|
||||
SuppressNotifications = suppressNotifications;
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
ChannelIds = NormalizeStringArray(channelIds);
|
||||
RuleIds = NormalizeStringArray(ruleIds);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
|
||||
if (EndsAt <= StartsAt)
|
||||
{
|
||||
throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt));
|
||||
}
|
||||
}
|
||||
|
||||
public static NotifyMaintenanceWindow Create(
|
||||
string windowId,
|
||||
string tenantId,
|
||||
string name,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
bool suppressNotifications = true,
|
||||
string? reason = null,
|
||||
IEnumerable<string>? channelIds = null,
|
||||
IEnumerable<string>? ruleIds = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyMaintenanceWindow(
|
||||
windowId,
|
||||
tenantId,
|
||||
name,
|
||||
startsAt,
|
||||
endsAt,
|
||||
suppressNotifications,
|
||||
reason,
|
||||
ToImmutableArray(channelIds),
|
||||
ToImmutableArray(ruleIds),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string WindowId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public DateTimeOffset StartsAt { get; }
|
||||
|
||||
public DateTimeOffset EndsAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to suppress notifications during the maintenance window.
|
||||
/// </summary>
|
||||
public bool SuppressNotifications { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the maintenance window.
|
||||
/// </summary>
|
||||
public string? Reason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of channel IDs to scope the maintenance window.
|
||||
/// If empty, applies to all channels.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ChannelIds { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of rule IDs to scope the maintenance window.
|
||||
/// If empty, applies to all rules.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RuleIds { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the maintenance window is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> SuppressNotifications && timestamp >= StartsAt && timestamp < EndsAt;
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringArray(ImmutableArray<string> values)
|
||||
{
|
||||
if (values.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(static v => v.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
|
||||
=> values is null ? ImmutableArray<string>.Empty : values.ToImmutableArray();
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator override for quiet hours or throttle configuration.
|
||||
/// Allows an operator to temporarily bypass quiet hours or throttling.
|
||||
/// </summary>
|
||||
public sealed record NotifyOperatorOverride
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOperatorOverride(
|
||||
string overrideId,
|
||||
string tenantId,
|
||||
NotifyOverrideType overrideType,
|
||||
DateTimeOffset expiresAt,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
OverrideType = overrideType;
|
||||
ExpiresAt = NotifyValidation.EnsureUtc(expiresAt);
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
RuleId = NotifyValidation.TrimToNull(ruleId);
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public static NotifyOperatorOverride Create(
|
||||
string overrideId,
|
||||
string tenantId,
|
||||
NotifyOverrideType overrideType,
|
||||
DateTimeOffset expiresAt,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new NotifyOperatorOverride(
|
||||
overrideId,
|
||||
tenantId,
|
||||
overrideType,
|
||||
expiresAt,
|
||||
channelId,
|
||||
ruleId,
|
||||
reason,
|
||||
createdBy,
|
||||
createdAt);
|
||||
}
|
||||
|
||||
public string OverrideId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public NotifyOverrideType OverrideType { get; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope the override.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional rule ID to scope the override.
|
||||
/// </summary>
|
||||
public string? RuleId { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the override is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> timestamp < ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of operator override.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyOverrideType
|
||||
{
|
||||
/// <summary>
|
||||
/// Bypass quiet hours.
|
||||
/// </summary>
|
||||
BypassQuietHours,
|
||||
|
||||
/// <summary>
|
||||
/// Bypass throttling.
|
||||
/// </summary>
|
||||
BypassThrottle,
|
||||
|
||||
/// <summary>
|
||||
/// Bypass maintenance window.
|
||||
/// </summary>
|
||||
BypassMaintenance,
|
||||
|
||||
/// <summary>
|
||||
/// Force suppress notifications.
|
||||
/// </summary>
|
||||
ForceSuppression
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Quiet hours schedule configuration for suppressing notifications during specified periods.
|
||||
/// </summary>
|
||||
public sealed record NotifyQuietHoursSchedule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyQuietHoursSchedule(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string cronExpression,
|
||||
TimeSpan duration,
|
||||
string timeZone,
|
||||
string? channelId = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
CronExpression = NotifyValidation.EnsureNotNullOrWhiteSpace(cronExpression, nameof(cronExpression));
|
||||
Duration = duration > TimeSpan.Zero ? duration : TimeSpan.FromHours(8);
|
||||
TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone));
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyQuietHoursSchedule Create(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string cronExpression,
|
||||
TimeSpan duration,
|
||||
string timeZone,
|
||||
string? channelId = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyQuietHoursSchedule(
|
||||
scheduleId,
|
||||
tenantId,
|
||||
name,
|
||||
cronExpression,
|
||||
duration,
|
||||
timeZone,
|
||||
channelId,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Cron expression defining when quiet hours start.
|
||||
/// </summary>
|
||||
public string CronExpression { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the quiet hours window.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IANA time zone for evaluating the cron expression (e.g., "America/New_York").
|
||||
/// </summary>
|
||||
public string TimeZone { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope quiet hours to a specific channel.
|
||||
/// If null, applies to all channels.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maintenance window for planned suppression of notifications.
|
||||
/// </summary>
|
||||
public sealed record NotifyMaintenanceWindow
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyMaintenanceWindow(
|
||||
string windowId,
|
||||
string tenantId,
|
||||
string name,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
bool suppressNotifications = true,
|
||||
string? reason = null,
|
||||
ImmutableArray<string> channelIds = default,
|
||||
ImmutableArray<string> ruleIds = default,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
WindowId = NotifyValidation.EnsureNotNullOrWhiteSpace(windowId, nameof(windowId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
StartsAt = NotifyValidation.EnsureUtc(startsAt);
|
||||
EndsAt = NotifyValidation.EnsureUtc(endsAt);
|
||||
SuppressNotifications = suppressNotifications;
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
ChannelIds = NormalizeStringArray(channelIds);
|
||||
RuleIds = NormalizeStringArray(ruleIds);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
|
||||
if (EndsAt <= StartsAt)
|
||||
{
|
||||
throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt));
|
||||
}
|
||||
}
|
||||
|
||||
public static NotifyMaintenanceWindow Create(
|
||||
string windowId,
|
||||
string tenantId,
|
||||
string name,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
bool suppressNotifications = true,
|
||||
string? reason = null,
|
||||
IEnumerable<string>? channelIds = null,
|
||||
IEnumerable<string>? ruleIds = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyMaintenanceWindow(
|
||||
windowId,
|
||||
tenantId,
|
||||
name,
|
||||
startsAt,
|
||||
endsAt,
|
||||
suppressNotifications,
|
||||
reason,
|
||||
ToImmutableArray(channelIds),
|
||||
ToImmutableArray(ruleIds),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string WindowId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public DateTimeOffset StartsAt { get; }
|
||||
|
||||
public DateTimeOffset EndsAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to suppress notifications during the maintenance window.
|
||||
/// </summary>
|
||||
public bool SuppressNotifications { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the maintenance window.
|
||||
/// </summary>
|
||||
public string? Reason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of channel IDs to scope the maintenance window.
|
||||
/// If empty, applies to all channels.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ChannelIds { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of rule IDs to scope the maintenance window.
|
||||
/// If empty, applies to all rules.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RuleIds { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the maintenance window is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> SuppressNotifications && timestamp >= StartsAt && timestamp < EndsAt;
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringArray(ImmutableArray<string> values)
|
||||
{
|
||||
if (values.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(static v => v.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
|
||||
=> values is null ? ImmutableArray<string>.Empty : values.ToImmutableArray();
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator override for quiet hours or throttle configuration.
|
||||
/// Allows an operator to temporarily bypass quiet hours or throttling.
|
||||
/// </summary>
|
||||
public sealed record NotifyOperatorOverride
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOperatorOverride(
|
||||
string overrideId,
|
||||
string tenantId,
|
||||
NotifyOverrideType overrideType,
|
||||
DateTimeOffset expiresAt,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
OverrideType = overrideType;
|
||||
ExpiresAt = NotifyValidation.EnsureUtc(expiresAt);
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
RuleId = NotifyValidation.TrimToNull(ruleId);
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public static NotifyOperatorOverride Create(
|
||||
string overrideId,
|
||||
string tenantId,
|
||||
NotifyOverrideType overrideType,
|
||||
DateTimeOffset expiresAt,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new NotifyOperatorOverride(
|
||||
overrideId,
|
||||
tenantId,
|
||||
overrideType,
|
||||
expiresAt,
|
||||
channelId,
|
||||
ruleId,
|
||||
reason,
|
||||
createdBy,
|
||||
createdAt);
|
||||
}
|
||||
|
||||
public string OverrideId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public NotifyOverrideType OverrideType { get; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope the override.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional rule ID to scope the override.
|
||||
/// </summary>
|
||||
public string? RuleId { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the override is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> timestamp < ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of operator override.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyOverrideType
|
||||
{
|
||||
/// <summary>
|
||||
/// Bypass quiet hours.
|
||||
/// </summary>
|
||||
BypassQuietHours,
|
||||
|
||||
/// <summary>
|
||||
/// Bypass throttling.
|
||||
/// </summary>
|
||||
BypassThrottle,
|
||||
|
||||
/// <summary>
|
||||
/// Bypass maintenance window.
|
||||
/// </summary>
|
||||
BypassMaintenance,
|
||||
|
||||
/// <summary>
|
||||
/// Force suppress notifications.
|
||||
/// </summary>
|
||||
ForceSuppression
|
||||
}
|
||||
|
||||
@@ -1,388 +1,388 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Rule definition describing how platform events are matched and routed to delivery actions.
|
||||
/// </summary>
|
||||
public sealed record NotifyRule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRule(
|
||||
string ruleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyRuleMatch match,
|
||||
ImmutableArray<NotifyRuleAction> actions,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? labels = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
SchemaVersion = NotifySchemaVersions.EnsureRule(schemaVersion);
|
||||
RuleId = NotifyValidation.EnsureNotNullOrWhiteSpace(ruleId, nameof(ruleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Match = match ?? throw new ArgumentNullException(nameof(match));
|
||||
Enabled = enabled;
|
||||
|
||||
Actions = NormalizeActions(actions);
|
||||
if (Actions.IsDefaultOrEmpty)
|
||||
{
|
||||
throw new ArgumentException("At least one action is required.", nameof(actions));
|
||||
}
|
||||
|
||||
Labels = NotifyValidation.NormalizeStringDictionary(labels);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyRule Create(
|
||||
string ruleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyRuleMatch match,
|
||||
IEnumerable<NotifyRuleAction>? actions,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? labels = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
return new NotifyRule(
|
||||
ruleId,
|
||||
tenantId,
|
||||
name,
|
||||
match,
|
||||
ToImmutableArray(actions),
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(labels),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string RuleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public NotifyRuleMatch Match { get; }
|
||||
|
||||
public ImmutableArray<NotifyRuleAction> Actions { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Labels { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableArray<NotifyRuleAction> NormalizeActions(ImmutableArray<NotifyRuleAction> actions)
|
||||
{
|
||||
var source = actions.IsDefault ? Array.Empty<NotifyRuleAction>() : actions.AsEnumerable();
|
||||
return source
|
||||
.Where(static action => action is not null)
|
||||
.Distinct()
|
||||
.OrderBy(static action => action.ActionId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyRuleAction> ToImmutableArray(IEnumerable<NotifyRuleAction>? actions)
|
||||
{
|
||||
if (actions is null)
|
||||
{
|
||||
return ImmutableArray<NotifyRuleAction>.Empty;
|
||||
}
|
||||
|
||||
return actions.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matching criteria used to evaluate whether an event should trigger the rule.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleMatch
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRuleMatch(
|
||||
ImmutableArray<string> eventKinds,
|
||||
ImmutableArray<string> namespaces,
|
||||
ImmutableArray<string> repositories,
|
||||
ImmutableArray<string> digests,
|
||||
ImmutableArray<string> labels,
|
||||
ImmutableArray<string> componentPurls,
|
||||
string? minSeverity,
|
||||
ImmutableArray<string> verdicts,
|
||||
bool? kevOnly,
|
||||
NotifyRuleMatchVex? vex)
|
||||
{
|
||||
EventKinds = NormalizeStringSet(eventKinds, lowerCase: true);
|
||||
Namespaces = NormalizeStringSet(namespaces);
|
||||
Repositories = NormalizeStringSet(repositories);
|
||||
Digests = NormalizeStringSet(digests, lowerCase: true);
|
||||
Labels = NormalizeStringSet(labels);
|
||||
ComponentPurls = NormalizeStringSet(componentPurls);
|
||||
Verdicts = NormalizeStringSet(verdicts, lowerCase: true);
|
||||
MinSeverity = NotifyValidation.TrimToNull(minSeverity)?.ToLowerInvariant();
|
||||
KevOnly = kevOnly;
|
||||
Vex = vex;
|
||||
}
|
||||
|
||||
public static NotifyRuleMatch Create(
|
||||
IEnumerable<string>? eventKinds = null,
|
||||
IEnumerable<string>? namespaces = null,
|
||||
IEnumerable<string>? repositories = null,
|
||||
IEnumerable<string>? digests = null,
|
||||
IEnumerable<string>? labels = null,
|
||||
IEnumerable<string>? componentPurls = null,
|
||||
string? minSeverity = null,
|
||||
IEnumerable<string>? verdicts = null,
|
||||
bool? kevOnly = null,
|
||||
NotifyRuleMatchVex? vex = null)
|
||||
{
|
||||
return new NotifyRuleMatch(
|
||||
ToImmutableArray(eventKinds),
|
||||
ToImmutableArray(namespaces),
|
||||
ToImmutableArray(repositories),
|
||||
ToImmutableArray(digests),
|
||||
ToImmutableArray(labels),
|
||||
ToImmutableArray(componentPurls),
|
||||
minSeverity,
|
||||
ToImmutableArray(verdicts),
|
||||
kevOnly,
|
||||
vex);
|
||||
}
|
||||
|
||||
public ImmutableArray<string> EventKinds { get; }
|
||||
|
||||
public ImmutableArray<string> Namespaces { get; }
|
||||
|
||||
public ImmutableArray<string> Repositories { get; }
|
||||
|
||||
public ImmutableArray<string> Digests { get; }
|
||||
|
||||
public ImmutableArray<string> Labels { get; }
|
||||
|
||||
public ImmutableArray<string> ComponentPurls { get; }
|
||||
|
||||
public string? MinSeverity { get; }
|
||||
|
||||
public ImmutableArray<string> Verdicts { get; }
|
||||
|
||||
public bool? KevOnly { get; }
|
||||
|
||||
public NotifyRuleMatchVex? Vex { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringSet(ImmutableArray<string> values, bool lowerCase = false)
|
||||
{
|
||||
var enumerable = values.IsDefault ? Array.Empty<string>() : values.AsEnumerable();
|
||||
var normalized = NotifyValidation.NormalizeStringSet(enumerable);
|
||||
|
||||
if (!lowerCase)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized
|
||||
.Select(static value => value.ToLowerInvariant())
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additional VEX (Vulnerability Exploitability eXchange) gating options.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleMatchVex
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRuleMatchVex(
|
||||
bool includeAcceptedJustifications = true,
|
||||
bool includeRejectedJustifications = false,
|
||||
bool includeUnknownJustifications = false,
|
||||
ImmutableArray<string> justificationKinds = default)
|
||||
{
|
||||
IncludeAcceptedJustifications = includeAcceptedJustifications;
|
||||
IncludeRejectedJustifications = includeRejectedJustifications;
|
||||
IncludeUnknownJustifications = includeUnknownJustifications;
|
||||
JustificationKinds = NormalizeStringSet(justificationKinds);
|
||||
}
|
||||
|
||||
public static NotifyRuleMatchVex Create(
|
||||
bool includeAcceptedJustifications = true,
|
||||
bool includeRejectedJustifications = false,
|
||||
bool includeUnknownJustifications = false,
|
||||
IEnumerable<string>? justificationKinds = null)
|
||||
{
|
||||
return new NotifyRuleMatchVex(
|
||||
includeAcceptedJustifications,
|
||||
includeRejectedJustifications,
|
||||
includeUnknownJustifications,
|
||||
ToImmutableArray(justificationKinds));
|
||||
}
|
||||
|
||||
public bool IncludeAcceptedJustifications { get; }
|
||||
|
||||
public bool IncludeRejectedJustifications { get; }
|
||||
|
||||
public bool IncludeUnknownJustifications { get; }
|
||||
|
||||
public ImmutableArray<string> JustificationKinds { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringSet(ImmutableArray<string> values)
|
||||
{
|
||||
var enumerable = values.IsDefault ? Array.Empty<string>() : values.AsEnumerable();
|
||||
return NotifyValidation.NormalizeStringSet(enumerable);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action executed when a rule matches an event.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleAction
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRuleAction(
|
||||
string actionId,
|
||||
string channel,
|
||||
string? template = null,
|
||||
string? digest = null,
|
||||
TimeSpan? throttle = null,
|
||||
string? locale = null,
|
||||
bool enabled = true,
|
||||
ImmutableDictionary<string, string>? metadata = null)
|
||||
{
|
||||
ActionId = NotifyValidation.EnsureNotNullOrWhiteSpace(actionId, nameof(actionId));
|
||||
Channel = NotifyValidation.EnsureNotNullOrWhiteSpace(channel, nameof(channel));
|
||||
Template = NotifyValidation.TrimToNull(template);
|
||||
Digest = NotifyValidation.TrimToNull(digest);
|
||||
Locale = NotifyValidation.TrimToNull(locale)?.ToLowerInvariant();
|
||||
Enabled = enabled;
|
||||
Throttle = throttle is { Ticks: > 0 } ? throttle : null;
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
}
|
||||
|
||||
public static NotifyRuleAction Create(
|
||||
string actionId,
|
||||
string channel,
|
||||
string? template = null,
|
||||
string? digest = null,
|
||||
TimeSpan? throttle = null,
|
||||
string? locale = null,
|
||||
bool enabled = true,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null)
|
||||
{
|
||||
return new NotifyRuleAction(
|
||||
actionId,
|
||||
channel,
|
||||
template,
|
||||
digest,
|
||||
throttle,
|
||||
locale,
|
||||
enabled,
|
||||
ToImmutableDictionary(metadata));
|
||||
}
|
||||
|
||||
public string ActionId { get; }
|
||||
|
||||
public string Channel { get; }
|
||||
|
||||
public string? Template { get; }
|
||||
|
||||
public string? Digest { get; }
|
||||
|
||||
public TimeSpan? Throttle { get; }
|
||||
|
||||
public string? Locale { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Rule definition describing how platform events are matched and routed to delivery actions.
|
||||
/// </summary>
|
||||
public sealed record NotifyRule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRule(
|
||||
string ruleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyRuleMatch match,
|
||||
ImmutableArray<NotifyRuleAction> actions,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? labels = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
SchemaVersion = NotifySchemaVersions.EnsureRule(schemaVersion);
|
||||
RuleId = NotifyValidation.EnsureNotNullOrWhiteSpace(ruleId, nameof(ruleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Match = match ?? throw new ArgumentNullException(nameof(match));
|
||||
Enabled = enabled;
|
||||
|
||||
Actions = NormalizeActions(actions);
|
||||
if (Actions.IsDefaultOrEmpty)
|
||||
{
|
||||
throw new ArgumentException("At least one action is required.", nameof(actions));
|
||||
}
|
||||
|
||||
Labels = NotifyValidation.NormalizeStringDictionary(labels);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyRule Create(
|
||||
string ruleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
NotifyRuleMatch match,
|
||||
IEnumerable<NotifyRuleAction>? actions,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? labels = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
return new NotifyRule(
|
||||
ruleId,
|
||||
tenantId,
|
||||
name,
|
||||
match,
|
||||
ToImmutableArray(actions),
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(labels),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string RuleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public NotifyRuleMatch Match { get; }
|
||||
|
||||
public ImmutableArray<NotifyRuleAction> Actions { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Labels { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableArray<NotifyRuleAction> NormalizeActions(ImmutableArray<NotifyRuleAction> actions)
|
||||
{
|
||||
var source = actions.IsDefault ? Array.Empty<NotifyRuleAction>() : actions.AsEnumerable();
|
||||
return source
|
||||
.Where(static action => action is not null)
|
||||
.Distinct()
|
||||
.OrderBy(static action => action.ActionId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyRuleAction> ToImmutableArray(IEnumerable<NotifyRuleAction>? actions)
|
||||
{
|
||||
if (actions is null)
|
||||
{
|
||||
return ImmutableArray<NotifyRuleAction>.Empty;
|
||||
}
|
||||
|
||||
return actions.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matching criteria used to evaluate whether an event should trigger the rule.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleMatch
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRuleMatch(
|
||||
ImmutableArray<string> eventKinds,
|
||||
ImmutableArray<string> namespaces,
|
||||
ImmutableArray<string> repositories,
|
||||
ImmutableArray<string> digests,
|
||||
ImmutableArray<string> labels,
|
||||
ImmutableArray<string> componentPurls,
|
||||
string? minSeverity,
|
||||
ImmutableArray<string> verdicts,
|
||||
bool? kevOnly,
|
||||
NotifyRuleMatchVex? vex)
|
||||
{
|
||||
EventKinds = NormalizeStringSet(eventKinds, lowerCase: true);
|
||||
Namespaces = NormalizeStringSet(namespaces);
|
||||
Repositories = NormalizeStringSet(repositories);
|
||||
Digests = NormalizeStringSet(digests, lowerCase: true);
|
||||
Labels = NormalizeStringSet(labels);
|
||||
ComponentPurls = NormalizeStringSet(componentPurls);
|
||||
Verdicts = NormalizeStringSet(verdicts, lowerCase: true);
|
||||
MinSeverity = NotifyValidation.TrimToNull(minSeverity)?.ToLowerInvariant();
|
||||
KevOnly = kevOnly;
|
||||
Vex = vex;
|
||||
}
|
||||
|
||||
public static NotifyRuleMatch Create(
|
||||
IEnumerable<string>? eventKinds = null,
|
||||
IEnumerable<string>? namespaces = null,
|
||||
IEnumerable<string>? repositories = null,
|
||||
IEnumerable<string>? digests = null,
|
||||
IEnumerable<string>? labels = null,
|
||||
IEnumerable<string>? componentPurls = null,
|
||||
string? minSeverity = null,
|
||||
IEnumerable<string>? verdicts = null,
|
||||
bool? kevOnly = null,
|
||||
NotifyRuleMatchVex? vex = null)
|
||||
{
|
||||
return new NotifyRuleMatch(
|
||||
ToImmutableArray(eventKinds),
|
||||
ToImmutableArray(namespaces),
|
||||
ToImmutableArray(repositories),
|
||||
ToImmutableArray(digests),
|
||||
ToImmutableArray(labels),
|
||||
ToImmutableArray(componentPurls),
|
||||
minSeverity,
|
||||
ToImmutableArray(verdicts),
|
||||
kevOnly,
|
||||
vex);
|
||||
}
|
||||
|
||||
public ImmutableArray<string> EventKinds { get; }
|
||||
|
||||
public ImmutableArray<string> Namespaces { get; }
|
||||
|
||||
public ImmutableArray<string> Repositories { get; }
|
||||
|
||||
public ImmutableArray<string> Digests { get; }
|
||||
|
||||
public ImmutableArray<string> Labels { get; }
|
||||
|
||||
public ImmutableArray<string> ComponentPurls { get; }
|
||||
|
||||
public string? MinSeverity { get; }
|
||||
|
||||
public ImmutableArray<string> Verdicts { get; }
|
||||
|
||||
public bool? KevOnly { get; }
|
||||
|
||||
public NotifyRuleMatchVex? Vex { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringSet(ImmutableArray<string> values, bool lowerCase = false)
|
||||
{
|
||||
var enumerable = values.IsDefault ? Array.Empty<string>() : values.AsEnumerable();
|
||||
var normalized = NotifyValidation.NormalizeStringSet(enumerable);
|
||||
|
||||
if (!lowerCase)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized
|
||||
.Select(static value => value.ToLowerInvariant())
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additional VEX (Vulnerability Exploitability eXchange) gating options.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleMatchVex
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRuleMatchVex(
|
||||
bool includeAcceptedJustifications = true,
|
||||
bool includeRejectedJustifications = false,
|
||||
bool includeUnknownJustifications = false,
|
||||
ImmutableArray<string> justificationKinds = default)
|
||||
{
|
||||
IncludeAcceptedJustifications = includeAcceptedJustifications;
|
||||
IncludeRejectedJustifications = includeRejectedJustifications;
|
||||
IncludeUnknownJustifications = includeUnknownJustifications;
|
||||
JustificationKinds = NormalizeStringSet(justificationKinds);
|
||||
}
|
||||
|
||||
public static NotifyRuleMatchVex Create(
|
||||
bool includeAcceptedJustifications = true,
|
||||
bool includeRejectedJustifications = false,
|
||||
bool includeUnknownJustifications = false,
|
||||
IEnumerable<string>? justificationKinds = null)
|
||||
{
|
||||
return new NotifyRuleMatchVex(
|
||||
includeAcceptedJustifications,
|
||||
includeRejectedJustifications,
|
||||
includeUnknownJustifications,
|
||||
ToImmutableArray(justificationKinds));
|
||||
}
|
||||
|
||||
public bool IncludeAcceptedJustifications { get; }
|
||||
|
||||
public bool IncludeRejectedJustifications { get; }
|
||||
|
||||
public bool IncludeUnknownJustifications { get; }
|
||||
|
||||
public ImmutableArray<string> JustificationKinds { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringSet(ImmutableArray<string> values)
|
||||
{
|
||||
var enumerable = values.IsDefault ? Array.Empty<string>() : values.AsEnumerable();
|
||||
return NotifyValidation.NormalizeStringSet(enumerable);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action executed when a rule matches an event.
|
||||
/// </summary>
|
||||
public sealed record NotifyRuleAction
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyRuleAction(
|
||||
string actionId,
|
||||
string channel,
|
||||
string? template = null,
|
||||
string? digest = null,
|
||||
TimeSpan? throttle = null,
|
||||
string? locale = null,
|
||||
bool enabled = true,
|
||||
ImmutableDictionary<string, string>? metadata = null)
|
||||
{
|
||||
ActionId = NotifyValidation.EnsureNotNullOrWhiteSpace(actionId, nameof(actionId));
|
||||
Channel = NotifyValidation.EnsureNotNullOrWhiteSpace(channel, nameof(channel));
|
||||
Template = NotifyValidation.TrimToNull(template);
|
||||
Digest = NotifyValidation.TrimToNull(digest);
|
||||
Locale = NotifyValidation.TrimToNull(locale)?.ToLowerInvariant();
|
||||
Enabled = enabled;
|
||||
Throttle = throttle is { Ticks: > 0 } ? throttle : null;
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
}
|
||||
|
||||
public static NotifyRuleAction Create(
|
||||
string actionId,
|
||||
string channel,
|
||||
string? template = null,
|
||||
string? digest = null,
|
||||
TimeSpan? throttle = null,
|
||||
string? locale = null,
|
||||
bool enabled = true,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null)
|
||||
{
|
||||
return new NotifyRuleAction(
|
||||
actionId,
|
||||
channel,
|
||||
template,
|
||||
digest,
|
||||
throttle,
|
||||
locale,
|
||||
enabled,
|
||||
ToImmutableDictionary(metadata));
|
||||
}
|
||||
|
||||
public string ActionId { get; }
|
||||
|
||||
public string Channel { get; }
|
||||
|
||||
public string? Template { get; }
|
||||
|
||||
public string? Digest { get; }
|
||||
|
||||
public TimeSpan? Throttle { get; }
|
||||
|
||||
public string? Locale { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Upgrades Notify documents emitted by older schema revisions to the current DTOs.
|
||||
/// </summary>
|
||||
public static class NotifySchemaMigration
|
||||
{
|
||||
public static NotifyRule UpgradeRule(JsonNode document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Rule);
|
||||
|
||||
return schemaVersion switch
|
||||
{
|
||||
NotifySchemaVersions.Rule => Deserialize<NotifyRule>(clone),
|
||||
_ => throw new NotSupportedException($"Unsupported notify rule schema version '{schemaVersion}'.")
|
||||
};
|
||||
}
|
||||
|
||||
public static NotifyChannel UpgradeChannel(JsonNode document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Channel);
|
||||
|
||||
return schemaVersion switch
|
||||
{
|
||||
NotifySchemaVersions.Channel => Deserialize<NotifyChannel>(clone),
|
||||
_ => throw new NotSupportedException($"Unsupported notify channel schema version '{schemaVersion}'.")
|
||||
};
|
||||
}
|
||||
|
||||
public static NotifyTemplate UpgradeTemplate(JsonNode document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Template);
|
||||
|
||||
return schemaVersion switch
|
||||
{
|
||||
NotifySchemaVersions.Template => Deserialize<NotifyTemplate>(clone),
|
||||
_ => throw new NotSupportedException($"Unsupported notify template schema version '{schemaVersion}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private static (JsonObject Clone, string SchemaVersion) Normalize(JsonNode node, string fallback)
|
||||
{
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
throw new ArgumentException("Document must be a JSON object.", nameof(node));
|
||||
}
|
||||
|
||||
if (obj.DeepClone() is not JsonObject clone)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to clone document as JsonObject.");
|
||||
}
|
||||
|
||||
string schemaVersion;
|
||||
if (clone.TryGetPropertyValue("schemaVersion", out var value) && value is JsonValue jsonValue && jsonValue.TryGetValue(out string? version) && !string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
schemaVersion = version.Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
schemaVersion = fallback;
|
||||
clone["schemaVersion"] = schemaVersion;
|
||||
}
|
||||
|
||||
return (clone, schemaVersion);
|
||||
}
|
||||
|
||||
private static T Deserialize<T>(JsonObject json)
|
||||
=> NotifyCanonicalJsonSerializer.Deserialize<T>(json.ToJsonString());
|
||||
}
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Upgrades Notify documents emitted by older schema revisions to the current DTOs.
|
||||
/// </summary>
|
||||
public static class NotifySchemaMigration
|
||||
{
|
||||
public static NotifyRule UpgradeRule(JsonNode document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Rule);
|
||||
|
||||
return schemaVersion switch
|
||||
{
|
||||
NotifySchemaVersions.Rule => Deserialize<NotifyRule>(clone),
|
||||
_ => throw new NotSupportedException($"Unsupported notify rule schema version '{schemaVersion}'.")
|
||||
};
|
||||
}
|
||||
|
||||
public static NotifyChannel UpgradeChannel(JsonNode document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Channel);
|
||||
|
||||
return schemaVersion switch
|
||||
{
|
||||
NotifySchemaVersions.Channel => Deserialize<NotifyChannel>(clone),
|
||||
_ => throw new NotSupportedException($"Unsupported notify channel schema version '{schemaVersion}'.")
|
||||
};
|
||||
}
|
||||
|
||||
public static NotifyTemplate UpgradeTemplate(JsonNode document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Template);
|
||||
|
||||
return schemaVersion switch
|
||||
{
|
||||
NotifySchemaVersions.Template => Deserialize<NotifyTemplate>(clone),
|
||||
_ => throw new NotSupportedException($"Unsupported notify template schema version '{schemaVersion}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private static (JsonObject Clone, string SchemaVersion) Normalize(JsonNode node, string fallback)
|
||||
{
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
throw new ArgumentException("Document must be a JSON object.", nameof(node));
|
||||
}
|
||||
|
||||
if (obj.DeepClone() is not JsonObject clone)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to clone document as JsonObject.");
|
||||
}
|
||||
|
||||
string schemaVersion;
|
||||
if (clone.TryGetPropertyValue("schemaVersion", out var value) && value is JsonValue jsonValue && jsonValue.TryGetValue(out string? version) && !string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
schemaVersion = version.Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
schemaVersion = fallback;
|
||||
clone["schemaVersion"] = schemaVersion;
|
||||
}
|
||||
|
||||
return (clone, schemaVersion);
|
||||
}
|
||||
|
||||
private static T Deserialize<T>(JsonObject json)
|
||||
=> NotifyCanonicalJsonSerializer.Deserialize<T>(json.ToJsonString());
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical schema version identifiers for Notify documents.
|
||||
/// </summary>
|
||||
public static class NotifySchemaVersions
|
||||
{
|
||||
public const string Rule = "notify.rule@1";
|
||||
public const string Channel = "notify.channel@1";
|
||||
public const string Template = "notify.template@1";
|
||||
|
||||
public static string EnsureRule(string? value)
|
||||
=> Normalize(value, Rule);
|
||||
|
||||
public static string EnsureChannel(string? value)
|
||||
=> Normalize(value, Channel);
|
||||
|
||||
public static string EnsureTemplate(string? value)
|
||||
=> Normalize(value, Template);
|
||||
|
||||
private static string Normalize(string? value, string fallback)
|
||||
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical schema version identifiers for Notify documents.
|
||||
/// </summary>
|
||||
public static class NotifySchemaVersions
|
||||
{
|
||||
public const string Rule = "notify.rule@1";
|
||||
public const string Channel = "notify.channel@1";
|
||||
public const string Template = "notify.template@1";
|
||||
|
||||
public static string EnsureRule(string? value)
|
||||
=> Normalize(value, Rule);
|
||||
|
||||
public static string EnsureChannel(string? value)
|
||||
=> Normalize(value, Channel);
|
||||
|
||||
public static string EnsureTemplate(string? value)
|
||||
=> Normalize(value, Template);
|
||||
|
||||
private static string Normalize(string? value, string fallback)
|
||||
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
@@ -1,130 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Stored template metadata and content for channel-specific rendering.
|
||||
/// </summary>
|
||||
public sealed record NotifyTemplate
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyTemplate(
|
||||
string templateId,
|
||||
string tenantId,
|
||||
NotifyChannelType channelType,
|
||||
string key,
|
||||
string locale,
|
||||
string body,
|
||||
NotifyTemplateRenderMode renderMode = NotifyTemplateRenderMode.Markdown,
|
||||
NotifyDeliveryFormat format = NotifyDeliveryFormat.Json,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
SchemaVersion = NotifySchemaVersions.EnsureTemplate(schemaVersion);
|
||||
TemplateId = NotifyValidation.EnsureNotNullOrWhiteSpace(templateId, nameof(templateId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
ChannelType = channelType;
|
||||
Key = NotifyValidation.EnsureNotNullOrWhiteSpace(key, nameof(key));
|
||||
Locale = NotifyValidation.EnsureNotNullOrWhiteSpace(locale, nameof(locale)).ToLowerInvariant();
|
||||
Body = NotifyValidation.EnsureNotNullOrWhiteSpace(body, nameof(body));
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
RenderMode = renderMode;
|
||||
Format = format;
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyTemplate Create(
|
||||
string templateId,
|
||||
string tenantId,
|
||||
NotifyChannelType channelType,
|
||||
string key,
|
||||
string locale,
|
||||
string body,
|
||||
NotifyTemplateRenderMode renderMode = NotifyTemplateRenderMode.Markdown,
|
||||
NotifyDeliveryFormat format = NotifyDeliveryFormat.Json,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
return new NotifyTemplate(
|
||||
templateId,
|
||||
tenantId,
|
||||
channelType,
|
||||
key,
|
||||
locale,
|
||||
body,
|
||||
renderMode,
|
||||
format,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string TemplateId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public NotifyChannelType ChannelType { get; }
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public string Locale { get; }
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public NotifyTemplateRenderMode RenderMode { get; }
|
||||
|
||||
public NotifyDeliveryFormat Format { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Stored template metadata and content for channel-specific rendering.
|
||||
/// </summary>
|
||||
public sealed record NotifyTemplate
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyTemplate(
|
||||
string templateId,
|
||||
string tenantId,
|
||||
NotifyChannelType channelType,
|
||||
string key,
|
||||
string locale,
|
||||
string body,
|
||||
NotifyTemplateRenderMode renderMode = NotifyTemplateRenderMode.Markdown,
|
||||
NotifyDeliveryFormat format = NotifyDeliveryFormat.Json,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
SchemaVersion = NotifySchemaVersions.EnsureTemplate(schemaVersion);
|
||||
TemplateId = NotifyValidation.EnsureNotNullOrWhiteSpace(templateId, nameof(templateId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
ChannelType = channelType;
|
||||
Key = NotifyValidation.EnsureNotNullOrWhiteSpace(key, nameof(key));
|
||||
Locale = NotifyValidation.EnsureNotNullOrWhiteSpace(locale, nameof(locale)).ToLowerInvariant();
|
||||
Body = NotifyValidation.EnsureNotNullOrWhiteSpace(body, nameof(body));
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
RenderMode = renderMode;
|
||||
Format = format;
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyTemplate Create(
|
||||
string templateId,
|
||||
string tenantId,
|
||||
NotifyChannelType channelType,
|
||||
string key,
|
||||
string locale,
|
||||
string body,
|
||||
NotifyTemplateRenderMode renderMode = NotifyTemplateRenderMode.Markdown,
|
||||
NotifyDeliveryFormat format = NotifyDeliveryFormat.Json,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
return new NotifyTemplate(
|
||||
templateId,
|
||||
tenantId,
|
||||
channelType,
|
||||
key,
|
||||
locale,
|
||||
body,
|
||||
renderMode,
|
||||
format,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string TemplateId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public NotifyChannelType ChannelType { get; }
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public string Locale { get; }
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public NotifyTemplateRenderMode RenderMode { get; }
|
||||
|
||||
public NotifyDeliveryFormat Format { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +1,157 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Throttle configuration for rate-limiting notifications.
|
||||
/// </summary>
|
||||
public sealed record NotifyThrottleConfig
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyThrottleConfig(
|
||||
string configId,
|
||||
string tenantId,
|
||||
string name,
|
||||
TimeSpan defaultWindow,
|
||||
int? maxNotificationsPerWindow = null,
|
||||
string? channelId = null,
|
||||
bool isDefault = false,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ConfigId = NotifyValidation.EnsureNotNullOrWhiteSpace(configId, nameof(configId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
DefaultWindow = defaultWindow > TimeSpan.Zero ? defaultWindow : TimeSpan.FromMinutes(5);
|
||||
MaxNotificationsPerWindow = maxNotificationsPerWindow > 0 ? maxNotificationsPerWindow : null;
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
IsDefault = isDefault;
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyThrottleConfig Create(
|
||||
string configId,
|
||||
string tenantId,
|
||||
string name,
|
||||
TimeSpan defaultWindow,
|
||||
int? maxNotificationsPerWindow = null,
|
||||
string? channelId = null,
|
||||
bool isDefault = false,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyThrottleConfig(
|
||||
configId,
|
||||
tenantId,
|
||||
name,
|
||||
defaultWindow,
|
||||
maxNotificationsPerWindow,
|
||||
channelId,
|
||||
isDefault,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default throttle configuration for a tenant.
|
||||
/// </summary>
|
||||
public static NotifyThrottleConfig CreateDefault(
|
||||
string tenantId,
|
||||
TimeSpan? defaultWindow = null,
|
||||
string? createdBy = null)
|
||||
{
|
||||
return Create(
|
||||
configId: $"{tenantId}-default",
|
||||
tenantId: tenantId,
|
||||
name: "Default Throttle",
|
||||
defaultWindow: defaultWindow ?? TimeSpan.FromMinutes(5),
|
||||
maxNotificationsPerWindow: null,
|
||||
channelId: null,
|
||||
isDefault: true,
|
||||
enabled: true,
|
||||
description: "Default throttle configuration for the tenant.",
|
||||
metadata: null,
|
||||
createdBy: createdBy);
|
||||
}
|
||||
|
||||
public string ConfigId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Default throttle window duration. Notifications with the same correlation key
|
||||
/// within this window will be deduplicated.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum number of notifications allowed per window.
|
||||
/// If set, additional notifications beyond this limit will be suppressed.
|
||||
/// </summary>
|
||||
public int? MaxNotificationsPerWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope the throttle configuration.
|
||||
/// If null, applies to all channels or serves as the tenant default.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the default throttle configuration for the tenant.
|
||||
/// </summary>
|
||||
public bool IsDefault { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Throttle configuration for rate-limiting notifications.
|
||||
/// </summary>
|
||||
public sealed record NotifyThrottleConfig
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyThrottleConfig(
|
||||
string configId,
|
||||
string tenantId,
|
||||
string name,
|
||||
TimeSpan defaultWindow,
|
||||
int? maxNotificationsPerWindow = null,
|
||||
string? channelId = null,
|
||||
bool isDefault = false,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ConfigId = NotifyValidation.EnsureNotNullOrWhiteSpace(configId, nameof(configId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
DefaultWindow = defaultWindow > TimeSpan.Zero ? defaultWindow : TimeSpan.FromMinutes(5);
|
||||
MaxNotificationsPerWindow = maxNotificationsPerWindow > 0 ? maxNotificationsPerWindow : null;
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
IsDefault = isDefault;
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyThrottleConfig Create(
|
||||
string configId,
|
||||
string tenantId,
|
||||
string name,
|
||||
TimeSpan defaultWindow,
|
||||
int? maxNotificationsPerWindow = null,
|
||||
string? channelId = null,
|
||||
bool isDefault = false,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyThrottleConfig(
|
||||
configId,
|
||||
tenantId,
|
||||
name,
|
||||
defaultWindow,
|
||||
maxNotificationsPerWindow,
|
||||
channelId,
|
||||
isDefault,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default throttle configuration for a tenant.
|
||||
/// </summary>
|
||||
public static NotifyThrottleConfig CreateDefault(
|
||||
string tenantId,
|
||||
TimeSpan? defaultWindow = null,
|
||||
string? createdBy = null)
|
||||
{
|
||||
return Create(
|
||||
configId: $"{tenantId}-default",
|
||||
tenantId: tenantId,
|
||||
name: "Default Throttle",
|
||||
defaultWindow: defaultWindow ?? TimeSpan.FromMinutes(5),
|
||||
maxNotificationsPerWindow: null,
|
||||
channelId: null,
|
||||
isDefault: true,
|
||||
enabled: true,
|
||||
description: "Default throttle configuration for the tenant.",
|
||||
metadata: null,
|
||||
createdBy: createdBy);
|
||||
}
|
||||
|
||||
public string ConfigId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Default throttle window duration. Notifications with the same correlation key
|
||||
/// within this window will be deduplicated.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum number of notifications allowed per window.
|
||||
/// If set, additional notifications beyond this limit will be suppressed.
|
||||
/// </summary>
|
||||
public int? MaxNotificationsPerWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope the throttle configuration.
|
||||
/// If null, applies to all channels or serves as the tenant default.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the default throttle configuration for the tenant.
|
||||
/// </summary>
|
||||
public bool IsDefault { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight validation helpers shared across Notify model constructors.
|
||||
/// </summary>
|
||||
public static class NotifyValidation
|
||||
{
|
||||
public static string EnsureNotNullOrWhiteSpace(string value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
public static string? TrimToNull(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
public static ImmutableArray<string> NormalizeStringSet(IEnumerable<string>? values)
|
||||
=> (values ?? Array.Empty<string>())
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
public static ImmutableDictionary<string, string> NormalizeStringDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedKey = key.Trim();
|
||||
var normalizedValue = value?.Trim() ?? string.Empty;
|
||||
builder[normalizedKey] = normalizedValue;
|
||||
}
|
||||
|
||||
return ImmutableDictionary.CreateRange(StringComparer.Ordinal, builder);
|
||||
}
|
||||
|
||||
public static DateTimeOffset EnsureUtc(DateTimeOffset value)
|
||||
=> value.ToUniversalTime();
|
||||
|
||||
public static DateTimeOffset? EnsureUtc(DateTimeOffset? value)
|
||||
=> value?.ToUniversalTime();
|
||||
|
||||
public static JsonNode? NormalizeJsonNode(JsonNode? node)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject jsonObject:
|
||||
{
|
||||
var normalized = new JsonObject();
|
||||
foreach (var property in jsonObject
|
||||
.Where(static pair => pair.Key is not null)
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
normalized[property.Key!] = NormalizeJsonNode(property.Value?.DeepClone());
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
case JsonArray jsonArray:
|
||||
{
|
||||
var normalized = new JsonArray();
|
||||
foreach (var element in jsonArray)
|
||||
{
|
||||
normalized.Add(NormalizeJsonNode(element?.DeepClone()));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
default:
|
||||
return node.DeepClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight validation helpers shared across Notify model constructors.
|
||||
/// </summary>
|
||||
public static class NotifyValidation
|
||||
{
|
||||
public static string EnsureNotNullOrWhiteSpace(string value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
public static string? TrimToNull(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
public static ImmutableArray<string> NormalizeStringSet(IEnumerable<string>? values)
|
||||
=> (values ?? Array.Empty<string>())
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
public static ImmutableDictionary<string, string> NormalizeStringDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedKey = key.Trim();
|
||||
var normalizedValue = value?.Trim() ?? string.Empty;
|
||||
builder[normalizedKey] = normalizedValue;
|
||||
}
|
||||
|
||||
return ImmutableDictionary.CreateRange(StringComparer.Ordinal, builder);
|
||||
}
|
||||
|
||||
public static DateTimeOffset EnsureUtc(DateTimeOffset value)
|
||||
=> value.ToUniversalTime();
|
||||
|
||||
public static DateTimeOffset? EnsureUtc(DateTimeOffset? value)
|
||||
=> value?.ToUniversalTime();
|
||||
|
||||
public static JsonNode? NormalizeJsonNode(JsonNode? node)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject jsonObject:
|
||||
{
|
||||
var normalized = new JsonObject();
|
||||
foreach (var property in jsonObject
|
||||
.Where(static pair => pair.Key is not null)
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
normalized[property.Key!] = NormalizeJsonNode(property.Value?.DeepClone());
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
case JsonArray jsonArray:
|
||||
{
|
||||
var normalized = new JsonArray();
|
||||
foreach (var element in jsonArray)
|
||||
{
|
||||
normalized.Add(NormalizeJsonNode(element?.DeepClone()));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
default:
|
||||
return node.DeepClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NATS.Client.JetStream;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Nats;
|
||||
|
||||
internal sealed class NatsNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
|
||||
{
|
||||
private readonly NatsNotifyDeliveryQueue _queue;
|
||||
private readonly NatsJSMsg<byte[]> _message;
|
||||
private int _completed;
|
||||
|
||||
internal NatsNotifyDeliveryLease(
|
||||
NatsNotifyDeliveryQueue queue,
|
||||
NatsJSMsg<byte[]> message,
|
||||
string messageId,
|
||||
NotifyDeliveryQueueMessage payload,
|
||||
int attempt,
|
||||
string consumer,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string idempotencyKey)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_message = message;
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = payload ?? throw new ArgumentNullException(nameof(payload));
|
||||
Attempt = attempt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
IdempotencyKey = idempotencyKey ?? payload.IdempotencyKey;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; internal set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => Message.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string? PartitionKey => Message.PartitionKey;
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyDeliveryQueueMessage Message { get; }
|
||||
|
||||
internal NatsJSMsg<byte[]> RawMessage => _message;
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NATS.Client.JetStream;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Nats;
|
||||
|
||||
internal sealed class NatsNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
|
||||
{
|
||||
private readonly NatsNotifyDeliveryQueue _queue;
|
||||
private readonly NatsJSMsg<byte[]> _message;
|
||||
private int _completed;
|
||||
|
||||
internal NatsNotifyDeliveryLease(
|
||||
NatsNotifyDeliveryQueue queue,
|
||||
NatsJSMsg<byte[]> message,
|
||||
string messageId,
|
||||
NotifyDeliveryQueueMessage payload,
|
||||
int attempt,
|
||||
string consumer,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string idempotencyKey)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_message = message;
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = payload ?? throw new ArgumentNullException(nameof(payload));
|
||||
Attempt = attempt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
IdempotencyKey = idempotencyKey ?? payload.IdempotencyKey;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; internal set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => Message.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string? PartitionKey => Message.PartitionKey;
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyDeliveryQueueMessage Message { get; }
|
||||
|
||||
internal NatsJSMsg<byte[]> RawMessage => _message;
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,83 +1,83 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NATS.Client.JetStream;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Nats;
|
||||
|
||||
internal sealed class NatsNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
|
||||
{
|
||||
private readonly NatsNotifyEventQueue _queue;
|
||||
private readonly NatsJSMsg<byte[]> _message;
|
||||
private int _completed;
|
||||
|
||||
internal NatsNotifyEventLease(
|
||||
NatsNotifyEventQueue queue,
|
||||
NatsJSMsg<byte[]> message,
|
||||
string messageId,
|
||||
NotifyQueueEventMessage payload,
|
||||
int attempt,
|
||||
string consumer,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
if (EqualityComparer<NatsJSMsg<byte[]>>.Default.Equals(message, default))
|
||||
{
|
||||
throw new ArgumentException("Message must be provided.", nameof(message));
|
||||
}
|
||||
|
||||
_message = message;
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = payload ?? throw new ArgumentNullException(nameof(payload));
|
||||
Attempt = attempt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; internal set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => Message.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string? PartitionKey => Message.PartitionKey;
|
||||
|
||||
public string IdempotencyKey => Message.IdempotencyKey;
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyQueueEventMessage Message { get; }
|
||||
|
||||
internal NatsJSMsg<byte[]> RawMessage => _message;
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NATS.Client.JetStream;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Nats;
|
||||
|
||||
internal sealed class NatsNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
|
||||
{
|
||||
private readonly NatsNotifyEventQueue _queue;
|
||||
private readonly NatsJSMsg<byte[]> _message;
|
||||
private int _completed;
|
||||
|
||||
internal NatsNotifyEventLease(
|
||||
NatsNotifyEventQueue queue,
|
||||
NatsJSMsg<byte[]> message,
|
||||
string messageId,
|
||||
NotifyQueueEventMessage payload,
|
||||
int attempt,
|
||||
string consumer,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
if (EqualityComparer<NatsJSMsg<byte[]>>.Default.Equals(message, default))
|
||||
{
|
||||
throw new ArgumentException("Message must be provided.", nameof(message));
|
||||
}
|
||||
|
||||
_message = message;
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = payload ?? throw new ArgumentNullException(nameof(payload));
|
||||
Attempt = attempt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; internal set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => Message.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string? PartitionKey => Message.PartitionKey;
|
||||
|
||||
public string IdempotencyKey => Message.IdempotencyKey;
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyQueueEventMessage Message { get; }
|
||||
|
||||
internal NatsJSMsg<byte[]> RawMessage => _message;
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,55 +1,55 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue.Nats;
|
||||
using StellaOps.Notify.Queue.Redis;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
public sealed class NotifyDeliveryQueueHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly INotifyDeliveryQueue _queue;
|
||||
private readonly ILogger<NotifyDeliveryQueueHealthCheck> _logger;
|
||||
|
||||
public NotifyDeliveryQueueHealthCheck(
|
||||
INotifyDeliveryQueue queue,
|
||||
ILogger<NotifyDeliveryQueueHealthCheck> logger)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
switch (_queue)
|
||||
{
|
||||
case RedisNotifyDeliveryQueue redisQueue:
|
||||
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("Redis Notify delivery queue reachable.");
|
||||
|
||||
case NatsNotifyDeliveryQueue natsQueue:
|
||||
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("NATS Notify delivery queue reachable.");
|
||||
|
||||
default:
|
||||
return HealthCheckResult.Healthy("Notify delivery queue transport without dedicated ping returned healthy.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Notify delivery queue health check failed.");
|
||||
return new HealthCheckResult(
|
||||
context.Registration.FailureStatus,
|
||||
"Notify delivery queue transport unreachable.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue.Nats;
|
||||
using StellaOps.Notify.Queue.Redis;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
public sealed class NotifyDeliveryQueueHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly INotifyDeliveryQueue _queue;
|
||||
private readonly ILogger<NotifyDeliveryQueueHealthCheck> _logger;
|
||||
|
||||
public NotifyDeliveryQueueHealthCheck(
|
||||
INotifyDeliveryQueue queue,
|
||||
ILogger<NotifyDeliveryQueueHealthCheck> logger)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
switch (_queue)
|
||||
{
|
||||
case RedisNotifyDeliveryQueue redisQueue:
|
||||
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("Redis Notify delivery queue reachable.");
|
||||
|
||||
case NatsNotifyDeliveryQueue natsQueue:
|
||||
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("NATS Notify delivery queue reachable.");
|
||||
|
||||
default:
|
||||
return HealthCheckResult.Healthy("Notify delivery queue transport without dedicated ping returned healthy.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Notify delivery queue health check failed.");
|
||||
return new HealthCheckResult(
|
||||
context.Registration.FailureStatus,
|
||||
"Notify delivery queue transport unreachable.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Notify delivery queue abstraction.
|
||||
/// </summary>
|
||||
public sealed class NotifyDeliveryQueueOptions
|
||||
{
|
||||
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
|
||||
|
||||
public NotifyRedisDeliveryQueueOptions Redis { get; set; } = new();
|
||||
|
||||
public NotifyNatsDeliveryQueueOptions Nats { get; set; } = new();
|
||||
|
||||
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public int MaxDeliveryAttempts { get; set; } = 5;
|
||||
|
||||
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
public sealed class NotifyRedisDeliveryQueueOptions
|
||||
{
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
public int? Database { get; set; }
|
||||
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public string StreamName { get; set; } = "notify:deliveries";
|
||||
|
||||
public string ConsumerGroup { get; set; } = "notify-deliveries";
|
||||
|
||||
public string IdempotencyKeyPrefix { get; set; } = "notify:deliveries:idemp:";
|
||||
|
||||
public int? ApproximateMaxLength { get; set; }
|
||||
|
||||
public string DeadLetterStreamName { get; set; } = "notify:deliveries:dead";
|
||||
|
||||
public TimeSpan DeadLetterRetention { get; set; } = TimeSpan.FromDays(7);
|
||||
}
|
||||
|
||||
public sealed class NotifyNatsDeliveryQueueOptions
|
||||
{
|
||||
public string? Url { get; set; }
|
||||
|
||||
public string Stream { get; set; } = "NOTIFY_DELIVERIES";
|
||||
|
||||
public string Subject { get; set; } = "notify.deliveries";
|
||||
|
||||
public string DurableConsumer { get; set; } = "notify-deliveries";
|
||||
|
||||
public string DeadLetterStream { get; set; } = "NOTIFY_DELIVERIES_DEAD";
|
||||
|
||||
public string DeadLetterSubject { get; set; } = "notify.deliveries.dead";
|
||||
|
||||
public int MaxAckPending { get; set; } = 128;
|
||||
|
||||
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Notify delivery queue abstraction.
|
||||
/// </summary>
|
||||
public sealed class NotifyDeliveryQueueOptions
|
||||
{
|
||||
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
|
||||
|
||||
public NotifyRedisDeliveryQueueOptions Redis { get; set; } = new();
|
||||
|
||||
public NotifyNatsDeliveryQueueOptions Nats { get; set; } = new();
|
||||
|
||||
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public int MaxDeliveryAttempts { get; set; } = 5;
|
||||
|
||||
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
public sealed class NotifyRedisDeliveryQueueOptions
|
||||
{
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
public int? Database { get; set; }
|
||||
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public string StreamName { get; set; } = "notify:deliveries";
|
||||
|
||||
public string ConsumerGroup { get; set; } = "notify-deliveries";
|
||||
|
||||
public string IdempotencyKeyPrefix { get; set; } = "notify:deliveries:idemp:";
|
||||
|
||||
public int? ApproximateMaxLength { get; set; }
|
||||
|
||||
public string DeadLetterStreamName { get; set; } = "notify:deliveries:dead";
|
||||
|
||||
public TimeSpan DeadLetterRetention { get; set; } = TimeSpan.FromDays(7);
|
||||
}
|
||||
|
||||
public sealed class NotifyNatsDeliveryQueueOptions
|
||||
{
|
||||
public string? Url { get; set; }
|
||||
|
||||
public string Stream { get; set; } = "NOTIFY_DELIVERIES";
|
||||
|
||||
public string Subject { get; set; } = "notify.deliveries";
|
||||
|
||||
public string DurableConsumer { get; set; } = "notify-deliveries";
|
||||
|
||||
public string DeadLetterStream { get; set; } = "NOTIFY_DELIVERIES_DEAD";
|
||||
|
||||
public string DeadLetterSubject { get; set; } = "notify.deliveries.dead";
|
||||
|
||||
public int MaxAckPending { get; set; } = 128;
|
||||
|
||||
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
@@ -1,177 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Notify event queue abstraction.
|
||||
/// </summary>
|
||||
public sealed class NotifyEventQueueOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Transport backing the queue.
|
||||
/// </summary>
|
||||
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
|
||||
|
||||
/// <summary>
|
||||
/// Redis-specific configuration.
|
||||
/// </summary>
|
||||
public NotifyRedisEventQueueOptions Redis { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// NATS JetStream-specific configuration.
|
||||
/// </summary>
|
||||
public NotifyNatsEventQueueOptions Nats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Default lease duration to use when consumers do not specify one explicitly.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of deliveries before a message should be considered failed.
|
||||
/// </summary>
|
||||
public int MaxDeliveryAttempts { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Initial retry backoff applied when a message is released for retry.
|
||||
/// </summary>
|
||||
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Cap applied to exponential retry backoff.
|
||||
/// </summary>
|
||||
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Minimum idle window before a pending message becomes eligible for claim.
|
||||
/// </summary>
|
||||
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redis transport options for the Notify event queue.
|
||||
/// </summary>
|
||||
public sealed class NotifyRedisEventQueueOptions
|
||||
{
|
||||
private IReadOnlyList<NotifyRedisEventStreamOptions> _streams = new List<NotifyRedisEventStreamOptions>
|
||||
{
|
||||
NotifyRedisEventStreamOptions.ForDefaultStream()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for the Redis instance.
|
||||
/// </summary>
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional logical database to select when connecting.
|
||||
/// </summary>
|
||||
public int? Database { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time allowed for initial connection/consumer-group creation.
|
||||
/// </summary>
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// TTL applied to idempotency keys stored alongside events.
|
||||
/// </summary>
|
||||
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
|
||||
|
||||
/// <summary>
|
||||
/// Streams consumed by Notify. Ordering is preserved during leasing.
|
||||
/// </summary>
|
||||
public IReadOnlyList<NotifyRedisEventStreamOptions> Streams
|
||||
{
|
||||
get => _streams;
|
||||
set => _streams = value is null || value.Count == 0
|
||||
? new List<NotifyRedisEventStreamOptions> { NotifyRedisEventStreamOptions.ForDefaultStream() }
|
||||
: value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-Redis-stream options for the Notify event queue.
|
||||
/// </summary>
|
||||
public sealed class NotifyRedisEventStreamOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the Redis stream containing events.
|
||||
/// </summary>
|
||||
public string Stream { get; set; } = "notify:events";
|
||||
|
||||
/// <summary>
|
||||
/// Consumer group used by Notify workers.
|
||||
/// </summary>
|
||||
public string ConsumerGroup { get; set; } = "notify-workers";
|
||||
|
||||
/// <summary>
|
||||
/// Prefix used when storing idempotency keys in Redis.
|
||||
/// </summary>
|
||||
public string IdempotencyKeyPrefix { get; set; } = "notify:events:idemp:";
|
||||
|
||||
/// <summary>
|
||||
/// Approximate maximum length for the stream; when set Redis will trim entries.
|
||||
/// </summary>
|
||||
public int? ApproximateMaxLength { get; set; }
|
||||
|
||||
public static NotifyRedisEventStreamOptions ForDefaultStream()
|
||||
=> new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS JetStream options for the Notify event queue.
|
||||
/// </summary>
|
||||
public sealed class NotifyNatsEventQueueOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// URL for the JetStream-enabled NATS cluster.
|
||||
/// </summary>
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stream name carrying Notify events.
|
||||
/// </summary>
|
||||
public string Stream { get; set; } = "NOTIFY_EVENTS";
|
||||
|
||||
/// <summary>
|
||||
/// Subject that producers publish Notify events to.
|
||||
/// </summary>
|
||||
public string Subject { get; set; } = "notify.events";
|
||||
|
||||
/// <summary>
|
||||
/// Durable consumer identifier for Notify workers.
|
||||
/// </summary>
|
||||
public string DurableConsumer { get; set; } = "notify-workers";
|
||||
|
||||
/// <summary>
|
||||
/// Dead-letter stream name used when deliveries exhaust retry budget.
|
||||
/// </summary>
|
||||
public string DeadLetterStream { get; set; } = "NOTIFY_EVENTS_DEAD";
|
||||
|
||||
/// <summary>
|
||||
/// Subject used for dead-letter publications.
|
||||
/// </summary>
|
||||
public string DeadLetterSubject { get; set; } = "notify.events.dead";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum pending messages before backpressure is applied.
|
||||
/// </summary>
|
||||
public int MaxAckPending { get; set; } = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Visibility timeout applied to leased events.
|
||||
/// </summary>
|
||||
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Delay applied when releasing a message for retry.
|
||||
/// </summary>
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Idle heartbeat emitted by the server to detect consumer disconnects.
|
||||
/// </summary>
|
||||
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Notify event queue abstraction.
|
||||
/// </summary>
|
||||
public sealed class NotifyEventQueueOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Transport backing the queue.
|
||||
/// </summary>
|
||||
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
|
||||
|
||||
/// <summary>
|
||||
/// Redis-specific configuration.
|
||||
/// </summary>
|
||||
public NotifyRedisEventQueueOptions Redis { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// NATS JetStream-specific configuration.
|
||||
/// </summary>
|
||||
public NotifyNatsEventQueueOptions Nats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Default lease duration to use when consumers do not specify one explicitly.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of deliveries before a message should be considered failed.
|
||||
/// </summary>
|
||||
public int MaxDeliveryAttempts { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Initial retry backoff applied when a message is released for retry.
|
||||
/// </summary>
|
||||
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Cap applied to exponential retry backoff.
|
||||
/// </summary>
|
||||
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Minimum idle window before a pending message becomes eligible for claim.
|
||||
/// </summary>
|
||||
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redis transport options for the Notify event queue.
|
||||
/// </summary>
|
||||
public sealed class NotifyRedisEventQueueOptions
|
||||
{
|
||||
private IReadOnlyList<NotifyRedisEventStreamOptions> _streams = new List<NotifyRedisEventStreamOptions>
|
||||
{
|
||||
NotifyRedisEventStreamOptions.ForDefaultStream()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for the Redis instance.
|
||||
/// </summary>
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional logical database to select when connecting.
|
||||
/// </summary>
|
||||
public int? Database { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time allowed for initial connection/consumer-group creation.
|
||||
/// </summary>
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// TTL applied to idempotency keys stored alongside events.
|
||||
/// </summary>
|
||||
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
|
||||
|
||||
/// <summary>
|
||||
/// Streams consumed by Notify. Ordering is preserved during leasing.
|
||||
/// </summary>
|
||||
public IReadOnlyList<NotifyRedisEventStreamOptions> Streams
|
||||
{
|
||||
get => _streams;
|
||||
set => _streams = value is null || value.Count == 0
|
||||
? new List<NotifyRedisEventStreamOptions> { NotifyRedisEventStreamOptions.ForDefaultStream() }
|
||||
: value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-Redis-stream options for the Notify event queue.
|
||||
/// </summary>
|
||||
public sealed class NotifyRedisEventStreamOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the Redis stream containing events.
|
||||
/// </summary>
|
||||
public string Stream { get; set; } = "notify:events";
|
||||
|
||||
/// <summary>
|
||||
/// Consumer group used by Notify workers.
|
||||
/// </summary>
|
||||
public string ConsumerGroup { get; set; } = "notify-workers";
|
||||
|
||||
/// <summary>
|
||||
/// Prefix used when storing idempotency keys in Redis.
|
||||
/// </summary>
|
||||
public string IdempotencyKeyPrefix { get; set; } = "notify:events:idemp:";
|
||||
|
||||
/// <summary>
|
||||
/// Approximate maximum length for the stream; when set Redis will trim entries.
|
||||
/// </summary>
|
||||
public int? ApproximateMaxLength { get; set; }
|
||||
|
||||
public static NotifyRedisEventStreamOptions ForDefaultStream()
|
||||
=> new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS JetStream options for the Notify event queue.
|
||||
/// </summary>
|
||||
public sealed class NotifyNatsEventQueueOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// URL for the JetStream-enabled NATS cluster.
|
||||
/// </summary>
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stream name carrying Notify events.
|
||||
/// </summary>
|
||||
public string Stream { get; set; } = "NOTIFY_EVENTS";
|
||||
|
||||
/// <summary>
|
||||
/// Subject that producers publish Notify events to.
|
||||
/// </summary>
|
||||
public string Subject { get; set; } = "notify.events";
|
||||
|
||||
/// <summary>
|
||||
/// Durable consumer identifier for Notify workers.
|
||||
/// </summary>
|
||||
public string DurableConsumer { get; set; } = "notify-workers";
|
||||
|
||||
/// <summary>
|
||||
/// Dead-letter stream name used when deliveries exhaust retry budget.
|
||||
/// </summary>
|
||||
public string DeadLetterStream { get; set; } = "NOTIFY_EVENTS_DEAD";
|
||||
|
||||
/// <summary>
|
||||
/// Subject used for dead-letter publications.
|
||||
/// </summary>
|
||||
public string DeadLetterSubject { get; set; } = "notify.events.dead";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum pending messages before backpressure is applied.
|
||||
/// </summary>
|
||||
public int MaxAckPending { get; set; } = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Visibility timeout applied to leased events.
|
||||
/// </summary>
|
||||
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Delay applied when releasing a message for retry.
|
||||
/// </summary>
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Idle heartbeat emitted by the server to detect consumer disconnects.
|
||||
/// </summary>
|
||||
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
@@ -1,231 +1,231 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Message queued for Notify event processing.
|
||||
/// </summary>
|
||||
public sealed class NotifyQueueEventMessage
|
||||
{
|
||||
private readonly NotifyEvent _event;
|
||||
private readonly IReadOnlyDictionary<string, string> _attributes;
|
||||
|
||||
public NotifyQueueEventMessage(
|
||||
NotifyEvent @event,
|
||||
string stream,
|
||||
string? idempotencyKey = null,
|
||||
string? partitionKey = null,
|
||||
string? traceId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null)
|
||||
{
|
||||
_event = @event ?? throw new ArgumentNullException(nameof(@event));
|
||||
if (string.IsNullOrWhiteSpace(stream))
|
||||
{
|
||||
throw new ArgumentException("Stream must be provided.", nameof(stream));
|
||||
}
|
||||
|
||||
Stream = stream;
|
||||
IdempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey)
|
||||
? @event.EventId.ToString("N")
|
||||
: idempotencyKey!;
|
||||
PartitionKey = string.IsNullOrWhiteSpace(partitionKey) ? null : partitionKey.Trim();
|
||||
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
|
||||
_attributes = attributes is null
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public NotifyEvent Event => _event;
|
||||
|
||||
public string Stream { get; }
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string TenantId => _event.Tenant;
|
||||
|
||||
public string? PartitionKey { get; }
|
||||
|
||||
public string? TraceId { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => _attributes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message queued for channel delivery execution.
|
||||
/// </summary>
|
||||
public sealed class NotifyDeliveryQueueMessage
|
||||
{
|
||||
public const string DefaultStream = "notify:deliveries";
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _attributes;
|
||||
|
||||
public NotifyDeliveryQueueMessage(
|
||||
NotifyDelivery delivery,
|
||||
string channelId,
|
||||
NotifyChannelType channelType,
|
||||
string? stream = null,
|
||||
string? traceId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null)
|
||||
{
|
||||
Delivery = delivery ?? throw new ArgumentNullException(nameof(delivery));
|
||||
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
|
||||
ChannelType = channelType;
|
||||
Stream = string.IsNullOrWhiteSpace(stream) ? DefaultStream : stream!.Trim();
|
||||
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
|
||||
_attributes = attributes is null
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public NotifyDelivery Delivery { get; }
|
||||
|
||||
public string ChannelId { get; }
|
||||
|
||||
public NotifyChannelType ChannelType { get; }
|
||||
|
||||
public string Stream { get; }
|
||||
|
||||
public string? TraceId { get; }
|
||||
|
||||
public string TenantId => Delivery.TenantId;
|
||||
|
||||
public string IdempotencyKey => Delivery.DeliveryId;
|
||||
|
||||
public string PartitionKey => ChannelId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => _attributes;
|
||||
}
|
||||
|
||||
public readonly record struct NotifyQueueEnqueueResult(string MessageId, bool Deduplicated);
|
||||
|
||||
public sealed class NotifyQueueLeaseRequest
|
||||
{
|
||||
public NotifyQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(consumer))
|
||||
{
|
||||
throw new ArgumentException("Consumer must be provided.", nameof(consumer));
|
||||
}
|
||||
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
|
||||
}
|
||||
|
||||
if (leaseDuration <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
|
||||
}
|
||||
|
||||
Consumer = consumer;
|
||||
BatchSize = batchSize;
|
||||
LeaseDuration = leaseDuration;
|
||||
}
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public int BatchSize { get; }
|
||||
|
||||
public TimeSpan LeaseDuration { get; }
|
||||
}
|
||||
|
||||
public sealed class NotifyQueueClaimOptions
|
||||
{
|
||||
public NotifyQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claimantConsumer))
|
||||
{
|
||||
throw new ArgumentException("Consumer must be provided.", nameof(claimantConsumer));
|
||||
}
|
||||
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
|
||||
}
|
||||
|
||||
if (minIdleTime < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Minimum idle time cannot be negative.");
|
||||
}
|
||||
|
||||
ClaimantConsumer = claimantConsumer;
|
||||
BatchSize = batchSize;
|
||||
MinIdleTime = minIdleTime;
|
||||
}
|
||||
|
||||
public string ClaimantConsumer { get; }
|
||||
|
||||
public int BatchSize { get; }
|
||||
|
||||
public TimeSpan MinIdleTime { get; }
|
||||
}
|
||||
|
||||
public enum NotifyQueueReleaseDisposition
|
||||
{
|
||||
Retry,
|
||||
Abandon
|
||||
}
|
||||
|
||||
public interface INotifyQueue<TMessage>
|
||||
{
|
||||
ValueTask<NotifyQueueEnqueueResult> PublishAsync(TMessage message, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface INotifyQueueLease<out TMessage>
|
||||
{
|
||||
string MessageId { get; }
|
||||
|
||||
int Attempt { get; }
|
||||
|
||||
DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
DateTimeOffset LeaseExpiresAt { get; }
|
||||
|
||||
string Consumer { get; }
|
||||
|
||||
string Stream { get; }
|
||||
|
||||
string TenantId { get; }
|
||||
|
||||
string? PartitionKey { get; }
|
||||
|
||||
string IdempotencyKey { get; }
|
||||
|
||||
string? TraceId { get; }
|
||||
|
||||
IReadOnlyDictionary<string, string> Attributes { get; }
|
||||
|
||||
TMessage Message { get; }
|
||||
|
||||
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface INotifyEventQueue : INotifyQueue<NotifyQueueEventMessage>
|
||||
{
|
||||
}
|
||||
|
||||
public interface INotifyDeliveryQueue : INotifyQueue<NotifyDeliveryQueueMessage>
|
||||
{
|
||||
}
|
||||
|
||||
internal static class EmptyReadOnlyDictionary<TKey, TValue>
|
||||
where TKey : notnull
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
|
||||
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Message queued for Notify event processing.
|
||||
/// </summary>
|
||||
public sealed class NotifyQueueEventMessage
|
||||
{
|
||||
private readonly NotifyEvent _event;
|
||||
private readonly IReadOnlyDictionary<string, string> _attributes;
|
||||
|
||||
public NotifyQueueEventMessage(
|
||||
NotifyEvent @event,
|
||||
string stream,
|
||||
string? idempotencyKey = null,
|
||||
string? partitionKey = null,
|
||||
string? traceId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null)
|
||||
{
|
||||
_event = @event ?? throw new ArgumentNullException(nameof(@event));
|
||||
if (string.IsNullOrWhiteSpace(stream))
|
||||
{
|
||||
throw new ArgumentException("Stream must be provided.", nameof(stream));
|
||||
}
|
||||
|
||||
Stream = stream;
|
||||
IdempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey)
|
||||
? @event.EventId.ToString("N")
|
||||
: idempotencyKey!;
|
||||
PartitionKey = string.IsNullOrWhiteSpace(partitionKey) ? null : partitionKey.Trim();
|
||||
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
|
||||
_attributes = attributes is null
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public NotifyEvent Event => _event;
|
||||
|
||||
public string Stream { get; }
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string TenantId => _event.Tenant;
|
||||
|
||||
public string? PartitionKey { get; }
|
||||
|
||||
public string? TraceId { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => _attributes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message queued for channel delivery execution.
|
||||
/// </summary>
|
||||
public sealed class NotifyDeliveryQueueMessage
|
||||
{
|
||||
public const string DefaultStream = "notify:deliveries";
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _attributes;
|
||||
|
||||
public NotifyDeliveryQueueMessage(
|
||||
NotifyDelivery delivery,
|
||||
string channelId,
|
||||
NotifyChannelType channelType,
|
||||
string? stream = null,
|
||||
string? traceId = null,
|
||||
IReadOnlyDictionary<string, string>? attributes = null)
|
||||
{
|
||||
Delivery = delivery ?? throw new ArgumentNullException(nameof(delivery));
|
||||
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
|
||||
ChannelType = channelType;
|
||||
Stream = string.IsNullOrWhiteSpace(stream) ? DefaultStream : stream!.Trim();
|
||||
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
|
||||
_attributes = attributes is null
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public NotifyDelivery Delivery { get; }
|
||||
|
||||
public string ChannelId { get; }
|
||||
|
||||
public NotifyChannelType ChannelType { get; }
|
||||
|
||||
public string Stream { get; }
|
||||
|
||||
public string? TraceId { get; }
|
||||
|
||||
public string TenantId => Delivery.TenantId;
|
||||
|
||||
public string IdempotencyKey => Delivery.DeliveryId;
|
||||
|
||||
public string PartitionKey => ChannelId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => _attributes;
|
||||
}
|
||||
|
||||
public readonly record struct NotifyQueueEnqueueResult(string MessageId, bool Deduplicated);
|
||||
|
||||
public sealed class NotifyQueueLeaseRequest
|
||||
{
|
||||
public NotifyQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(consumer))
|
||||
{
|
||||
throw new ArgumentException("Consumer must be provided.", nameof(consumer));
|
||||
}
|
||||
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
|
||||
}
|
||||
|
||||
if (leaseDuration <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
|
||||
}
|
||||
|
||||
Consumer = consumer;
|
||||
BatchSize = batchSize;
|
||||
LeaseDuration = leaseDuration;
|
||||
}
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public int BatchSize { get; }
|
||||
|
||||
public TimeSpan LeaseDuration { get; }
|
||||
}
|
||||
|
||||
public sealed class NotifyQueueClaimOptions
|
||||
{
|
||||
public NotifyQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claimantConsumer))
|
||||
{
|
||||
throw new ArgumentException("Consumer must be provided.", nameof(claimantConsumer));
|
||||
}
|
||||
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
|
||||
}
|
||||
|
||||
if (minIdleTime < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Minimum idle time cannot be negative.");
|
||||
}
|
||||
|
||||
ClaimantConsumer = claimantConsumer;
|
||||
BatchSize = batchSize;
|
||||
MinIdleTime = minIdleTime;
|
||||
}
|
||||
|
||||
public string ClaimantConsumer { get; }
|
||||
|
||||
public int BatchSize { get; }
|
||||
|
||||
public TimeSpan MinIdleTime { get; }
|
||||
}
|
||||
|
||||
public enum NotifyQueueReleaseDisposition
|
||||
{
|
||||
Retry,
|
||||
Abandon
|
||||
}
|
||||
|
||||
public interface INotifyQueue<TMessage>
|
||||
{
|
||||
ValueTask<NotifyQueueEnqueueResult> PublishAsync(TMessage message, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface INotifyQueueLease<out TMessage>
|
||||
{
|
||||
string MessageId { get; }
|
||||
|
||||
int Attempt { get; }
|
||||
|
||||
DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
DateTimeOffset LeaseExpiresAt { get; }
|
||||
|
||||
string Consumer { get; }
|
||||
|
||||
string Stream { get; }
|
||||
|
||||
string TenantId { get; }
|
||||
|
||||
string? PartitionKey { get; }
|
||||
|
||||
string IdempotencyKey { get; }
|
||||
|
||||
string? TraceId { get; }
|
||||
|
||||
IReadOnlyDictionary<string, string> Attributes { get; }
|
||||
|
||||
TMessage Message { get; }
|
||||
|
||||
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface INotifyEventQueue : INotifyQueue<NotifyQueueEventMessage>
|
||||
{
|
||||
}
|
||||
|
||||
public interface INotifyDeliveryQueue : INotifyQueue<NotifyDeliveryQueueMessage>
|
||||
{
|
||||
}
|
||||
|
||||
internal static class EmptyReadOnlyDictionary<TKey, TValue>
|
||||
where TKey : notnull
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
|
||||
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
internal static class NotifyQueueFields
|
||||
{
|
||||
public const string Payload = "payload";
|
||||
public const string EventId = "eventId";
|
||||
public const string DeliveryId = "deliveryId";
|
||||
public const string Tenant = "tenant";
|
||||
public const string Kind = "kind";
|
||||
public const string Attempt = "attempt";
|
||||
public const string EnqueuedAt = "enqueuedAt";
|
||||
public const string TraceId = "traceId";
|
||||
public const string PartitionKey = "partitionKey";
|
||||
public const string ChannelId = "channelId";
|
||||
public const string ChannelType = "channelType";
|
||||
public const string IdempotencyKey = "idempotency";
|
||||
public const string AttributePrefix = "attr:";
|
||||
}
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
internal static class NotifyQueueFields
|
||||
{
|
||||
public const string Payload = "payload";
|
||||
public const string EventId = "eventId";
|
||||
public const string DeliveryId = "deliveryId";
|
||||
public const string Tenant = "tenant";
|
||||
public const string Kind = "kind";
|
||||
public const string Attempt = "attempt";
|
||||
public const string EnqueuedAt = "enqueuedAt";
|
||||
public const string TraceId = "traceId";
|
||||
public const string PartitionKey = "partitionKey";
|
||||
public const string ChannelId = "channelId";
|
||||
public const string ChannelType = "channelType";
|
||||
public const string IdempotencyKey = "idempotency";
|
||||
public const string AttributePrefix = "attr:";
|
||||
}
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue.Nats;
|
||||
using StellaOps.Notify.Queue.Redis;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
public sealed class NotifyQueueHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly INotifyEventQueue _queue;
|
||||
private readonly ILogger<NotifyQueueHealthCheck> _logger;
|
||||
|
||||
public NotifyQueueHealthCheck(
|
||||
INotifyEventQueue queue,
|
||||
ILogger<NotifyQueueHealthCheck> logger)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
switch (_queue)
|
||||
{
|
||||
case RedisNotifyEventQueue redisQueue:
|
||||
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("Redis Notify queue reachable.");
|
||||
|
||||
case NatsNotifyEventQueue natsQueue:
|
||||
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("NATS Notify queue reachable.");
|
||||
|
||||
default:
|
||||
return HealthCheckResult.Healthy("Notify queue transport without dedicated ping returned healthy.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Notify queue health check failed.");
|
||||
return new HealthCheckResult(
|
||||
context.Registration.FailureStatus,
|
||||
"Notify queue transport unreachable.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue.Nats;
|
||||
using StellaOps.Notify.Queue.Redis;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
public sealed class NotifyQueueHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly INotifyEventQueue _queue;
|
||||
private readonly ILogger<NotifyQueueHealthCheck> _logger;
|
||||
|
||||
public NotifyQueueHealthCheck(
|
||||
INotifyEventQueue queue,
|
||||
ILogger<NotifyQueueHealthCheck> logger)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
switch (_queue)
|
||||
{
|
||||
case RedisNotifyEventQueue redisQueue:
|
||||
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("Redis Notify queue reachable.");
|
||||
|
||||
case NatsNotifyEventQueue natsQueue:
|
||||
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
return HealthCheckResult.Healthy("NATS Notify queue reachable.");
|
||||
|
||||
default:
|
||||
return HealthCheckResult.Healthy("Notify queue transport without dedicated ping returned healthy.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Notify queue health check failed.");
|
||||
return new HealthCheckResult(
|
||||
context.Registration.FailureStatus,
|
||||
"Notify queue transport unreachable.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
internal static class NotifyQueueMetrics
|
||||
{
|
||||
private const string TransportTag = "transport";
|
||||
private const string StreamTag = "stream";
|
||||
|
||||
private static readonly Meter Meter = new("StellaOps.Notify.Queue");
|
||||
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("notify_queue_enqueued_total");
|
||||
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("notify_queue_deduplicated_total");
|
||||
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("notify_queue_ack_total");
|
||||
private static readonly Counter<long> RetryCounter = Meter.CreateCounter<long>("notify_queue_retry_total");
|
||||
private static readonly Counter<long> DeadLetterCounter = Meter.CreateCounter<long>("notify_queue_deadletter_total");
|
||||
|
||||
public static void RecordEnqueued(string transport, string stream)
|
||||
=> EnqueuedCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordDeduplicated(string transport, string stream)
|
||||
=> DeduplicatedCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordAck(string transport, string stream)
|
||||
=> AckCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordRetry(string transport, string stream)
|
||||
=> RetryCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordDeadLetter(string transport, string stream)
|
||||
=> DeadLetterCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
private static KeyValuePair<string, object?>[] BuildTags(string transport, string stream)
|
||||
=> new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>(TransportTag, transport),
|
||||
new KeyValuePair<string, object?>(StreamTag, stream)
|
||||
};
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
internal static class NotifyQueueMetrics
|
||||
{
|
||||
private const string TransportTag = "transport";
|
||||
private const string StreamTag = "stream";
|
||||
|
||||
private static readonly Meter Meter = new("StellaOps.Notify.Queue");
|
||||
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("notify_queue_enqueued_total");
|
||||
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("notify_queue_deduplicated_total");
|
||||
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("notify_queue_ack_total");
|
||||
private static readonly Counter<long> RetryCounter = Meter.CreateCounter<long>("notify_queue_retry_total");
|
||||
private static readonly Counter<long> DeadLetterCounter = Meter.CreateCounter<long>("notify_queue_deadletter_total");
|
||||
|
||||
public static void RecordEnqueued(string transport, string stream)
|
||||
=> EnqueuedCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordDeduplicated(string transport, string stream)
|
||||
=> DeduplicatedCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordAck(string transport, string stream)
|
||||
=> AckCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordRetry(string transport, string stream)
|
||||
=> RetryCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
public static void RecordDeadLetter(string transport, string stream)
|
||||
=> DeadLetterCounter.Add(1, BuildTags(transport, stream));
|
||||
|
||||
private static KeyValuePair<string, object?>[] BuildTags(string transport, string stream)
|
||||
=> new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>(TransportTag, transport),
|
||||
new KeyValuePair<string, object?>(StreamTag, stream)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,146 +1,146 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue.Nats;
|
||||
using StellaOps.Notify.Queue.Redis;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
public static class NotifyQueueServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddNotifyEventQueue(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "notify:queue")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var eventOptions = new NotifyEventQueueOptions();
|
||||
configuration.GetSection(sectionName).Bind(eventOptions);
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(eventOptions);
|
||||
|
||||
services.AddSingleton<INotifyEventQueue>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var opts = sp.GetRequiredService<NotifyEventQueueOptions>();
|
||||
|
||||
return opts.Transport switch
|
||||
{
|
||||
NotifyQueueTransportKind.Redis => new RedisNotifyEventQueue(
|
||||
opts,
|
||||
opts.Redis,
|
||||
loggerFactory.CreateLogger<RedisNotifyEventQueue>(),
|
||||
timeProvider),
|
||||
NotifyQueueTransportKind.Nats => new NatsNotifyEventQueue(
|
||||
opts,
|
||||
opts.Nats,
|
||||
loggerFactory.CreateLogger<NatsNotifyEventQueue>(),
|
||||
timeProvider),
|
||||
_ => throw new InvalidOperationException($"Unsupported Notify queue transport kind '{opts.Transport}'.")
|
||||
};
|
||||
});
|
||||
|
||||
services.AddSingleton<NotifyQueueHealthCheck>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddNotifyDeliveryQueue(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "notify:deliveryQueue")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var deliveryOptions = new NotifyDeliveryQueueOptions();
|
||||
configuration.GetSection(sectionName).Bind(deliveryOptions);
|
||||
|
||||
services.AddSingleton(deliveryOptions);
|
||||
|
||||
services.AddSingleton<INotifyDeliveryQueue>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var opts = sp.GetRequiredService<NotifyDeliveryQueueOptions>();
|
||||
var eventOpts = sp.GetService<NotifyEventQueueOptions>();
|
||||
|
||||
ApplyDeliveryFallbacks(opts, eventOpts);
|
||||
|
||||
return opts.Transport switch
|
||||
{
|
||||
NotifyQueueTransportKind.Redis => new RedisNotifyDeliveryQueue(
|
||||
opts,
|
||||
opts.Redis,
|
||||
loggerFactory.CreateLogger<RedisNotifyDeliveryQueue>(),
|
||||
timeProvider),
|
||||
NotifyQueueTransportKind.Nats => new NatsNotifyDeliveryQueue(
|
||||
opts,
|
||||
opts.Nats,
|
||||
loggerFactory.CreateLogger<NatsNotifyDeliveryQueue>(),
|
||||
timeProvider),
|
||||
_ => throw new InvalidOperationException($"Unsupported Notify delivery queue transport kind '{opts.Transport}'.")
|
||||
};
|
||||
});
|
||||
|
||||
services.AddSingleton<NotifyDeliveryQueueHealthCheck>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IHealthChecksBuilder AddNotifyQueueHealthCheck(
|
||||
this IHealthChecksBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.TryAddSingleton<NotifyQueueHealthCheck>();
|
||||
builder.AddCheck<NotifyQueueHealthCheck>(
|
||||
name: "notify-queue",
|
||||
failureStatus: HealthStatus.Unhealthy,
|
||||
tags: new[] { "notify", "queue" });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IHealthChecksBuilder AddNotifyDeliveryQueueHealthCheck(
|
||||
this IHealthChecksBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.TryAddSingleton<NotifyDeliveryQueueHealthCheck>();
|
||||
builder.AddCheck<NotifyDeliveryQueueHealthCheck>(
|
||||
name: "notify-delivery-queue",
|
||||
failureStatus: HealthStatus.Unhealthy,
|
||||
tags: new[] { "notify", "queue", "delivery" });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void ApplyDeliveryFallbacks(
|
||||
NotifyDeliveryQueueOptions deliveryOptions,
|
||||
NotifyEventQueueOptions? eventOptions)
|
||||
{
|
||||
if (eventOptions is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deliveryOptions.Redis.ConnectionString))
|
||||
{
|
||||
deliveryOptions.Redis.ConnectionString = eventOptions.Redis.ConnectionString;
|
||||
deliveryOptions.Redis.Database ??= eventOptions.Redis.Database;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deliveryOptions.Nats.Url))
|
||||
{
|
||||
deliveryOptions.Nats.Url = eventOptions.Nats.Url;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue.Nats;
|
||||
using StellaOps.Notify.Queue.Redis;
|
||||
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
public static class NotifyQueueServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddNotifyEventQueue(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "notify:queue")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var eventOptions = new NotifyEventQueueOptions();
|
||||
configuration.GetSection(sectionName).Bind(eventOptions);
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(eventOptions);
|
||||
|
||||
services.AddSingleton<INotifyEventQueue>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var opts = sp.GetRequiredService<NotifyEventQueueOptions>();
|
||||
|
||||
return opts.Transport switch
|
||||
{
|
||||
NotifyQueueTransportKind.Redis => new RedisNotifyEventQueue(
|
||||
opts,
|
||||
opts.Redis,
|
||||
loggerFactory.CreateLogger<RedisNotifyEventQueue>(),
|
||||
timeProvider),
|
||||
NotifyQueueTransportKind.Nats => new NatsNotifyEventQueue(
|
||||
opts,
|
||||
opts.Nats,
|
||||
loggerFactory.CreateLogger<NatsNotifyEventQueue>(),
|
||||
timeProvider),
|
||||
_ => throw new InvalidOperationException($"Unsupported Notify queue transport kind '{opts.Transport}'.")
|
||||
};
|
||||
});
|
||||
|
||||
services.AddSingleton<NotifyQueueHealthCheck>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddNotifyDeliveryQueue(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "notify:deliveryQueue")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var deliveryOptions = new NotifyDeliveryQueueOptions();
|
||||
configuration.GetSection(sectionName).Bind(deliveryOptions);
|
||||
|
||||
services.AddSingleton(deliveryOptions);
|
||||
|
||||
services.AddSingleton<INotifyDeliveryQueue>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var opts = sp.GetRequiredService<NotifyDeliveryQueueOptions>();
|
||||
var eventOpts = sp.GetService<NotifyEventQueueOptions>();
|
||||
|
||||
ApplyDeliveryFallbacks(opts, eventOpts);
|
||||
|
||||
return opts.Transport switch
|
||||
{
|
||||
NotifyQueueTransportKind.Redis => new RedisNotifyDeliveryQueue(
|
||||
opts,
|
||||
opts.Redis,
|
||||
loggerFactory.CreateLogger<RedisNotifyDeliveryQueue>(),
|
||||
timeProvider),
|
||||
NotifyQueueTransportKind.Nats => new NatsNotifyDeliveryQueue(
|
||||
opts,
|
||||
opts.Nats,
|
||||
loggerFactory.CreateLogger<NatsNotifyDeliveryQueue>(),
|
||||
timeProvider),
|
||||
_ => throw new InvalidOperationException($"Unsupported Notify delivery queue transport kind '{opts.Transport}'.")
|
||||
};
|
||||
});
|
||||
|
||||
services.AddSingleton<NotifyDeliveryQueueHealthCheck>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IHealthChecksBuilder AddNotifyQueueHealthCheck(
|
||||
this IHealthChecksBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.TryAddSingleton<NotifyQueueHealthCheck>();
|
||||
builder.AddCheck<NotifyQueueHealthCheck>(
|
||||
name: "notify-queue",
|
||||
failureStatus: HealthStatus.Unhealthy,
|
||||
tags: new[] { "notify", "queue" });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IHealthChecksBuilder AddNotifyDeliveryQueueHealthCheck(
|
||||
this IHealthChecksBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.TryAddSingleton<NotifyDeliveryQueueHealthCheck>();
|
||||
builder.AddCheck<NotifyDeliveryQueueHealthCheck>(
|
||||
name: "notify-delivery-queue",
|
||||
failureStatus: HealthStatus.Unhealthy,
|
||||
tags: new[] { "notify", "queue", "delivery" });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void ApplyDeliveryFallbacks(
|
||||
NotifyDeliveryQueueOptions deliveryOptions,
|
||||
NotifyEventQueueOptions? eventOptions)
|
||||
{
|
||||
if (eventOptions is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deliveryOptions.Redis.ConnectionString))
|
||||
{
|
||||
deliveryOptions.Redis.ConnectionString = eventOptions.Redis.ConnectionString;
|
||||
deliveryOptions.Redis.Database ??= eventOptions.Redis.Database;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deliveryOptions.Nats.Url))
|
||||
{
|
||||
deliveryOptions.Nats.Url = eventOptions.Nats.Url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Supported transports for the Notify event queue.
|
||||
/// </summary>
|
||||
public enum NotifyQueueTransportKind
|
||||
{
|
||||
Redis,
|
||||
Nats
|
||||
}
|
||||
namespace StellaOps.Notify.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Supported transports for the Notify event queue.
|
||||
/// </summary>
|
||||
public enum NotifyQueueTransportKind
|
||||
{
|
||||
Redis,
|
||||
Nats
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Notify.Queue.Tests")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Notify.Queue.Tests")]
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Redis;
|
||||
|
||||
internal sealed class RedisNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
|
||||
{
|
||||
private readonly RedisNotifyDeliveryQueue _queue;
|
||||
private int _completed;
|
||||
|
||||
internal RedisNotifyDeliveryLease(
|
||||
RedisNotifyDeliveryQueue queue,
|
||||
string messageId,
|
||||
NotifyDeliveryQueueMessage message,
|
||||
int attempt,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer,
|
||||
string? idempotencyKey,
|
||||
string partitionKey)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = message ?? throw new ArgumentNullException(nameof(message));
|
||||
Attempt = attempt;
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
IdempotencyKey = idempotencyKey ?? message.IdempotencyKey;
|
||||
PartitionKey = partitionKey ?? message.ChannelId;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; internal set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => Message.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string PartitionKey { get; }
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyDeliveryQueueMessage Message { get; }
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Redis;
|
||||
|
||||
internal sealed class RedisNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
|
||||
{
|
||||
private readonly RedisNotifyDeliveryQueue _queue;
|
||||
private int _completed;
|
||||
|
||||
internal RedisNotifyDeliveryLease(
|
||||
RedisNotifyDeliveryQueue queue,
|
||||
string messageId,
|
||||
NotifyDeliveryQueueMessage message,
|
||||
int attempt,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer,
|
||||
string? idempotencyKey,
|
||||
string partitionKey)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = message ?? throw new ArgumentNullException(nameof(message));
|
||||
Attempt = attempt;
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
IdempotencyKey = idempotencyKey ?? message.IdempotencyKey;
|
||||
PartitionKey = partitionKey ?? message.ChannelId;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; internal set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => Message.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string PartitionKey { get; }
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyDeliveryQueueMessage Message { get; }
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,76 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Redis;
|
||||
|
||||
internal sealed class RedisNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
|
||||
{
|
||||
private readonly RedisNotifyEventQueue _queue;
|
||||
private int _completed;
|
||||
|
||||
internal RedisNotifyEventLease(
|
||||
RedisNotifyEventQueue queue,
|
||||
NotifyRedisEventStreamOptions streamOptions,
|
||||
string messageId,
|
||||
NotifyQueueEventMessage message,
|
||||
int attempt,
|
||||
string consumer,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
StreamOptions = streamOptions ?? throw new ArgumentNullException(nameof(streamOptions));
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = message ?? throw new ArgumentNullException(nameof(message));
|
||||
Attempt = attempt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
}
|
||||
|
||||
internal NotifyRedisEventStreamOptions StreamOptions { get; }
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => StreamOptions.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string? PartitionKey => Message.PartitionKey;
|
||||
|
||||
public string IdempotencyKey => Message.IdempotencyKey;
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyQueueEventMessage Message { get; }
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Redis;
|
||||
|
||||
internal sealed class RedisNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
|
||||
{
|
||||
private readonly RedisNotifyEventQueue _queue;
|
||||
private int _completed;
|
||||
|
||||
internal RedisNotifyEventLease(
|
||||
RedisNotifyEventQueue queue,
|
||||
NotifyRedisEventStreamOptions streamOptions,
|
||||
string messageId,
|
||||
NotifyQueueEventMessage message,
|
||||
int attempt,
|
||||
string consumer,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
StreamOptions = streamOptions ?? throw new ArgumentNullException(nameof(streamOptions));
|
||||
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
|
||||
Message = message ?? throw new ArgumentNullException(nameof(message));
|
||||
Attempt = attempt;
|
||||
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
}
|
||||
|
||||
internal NotifyRedisEventStreamOptions StreamOptions { get; }
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public int Attempt { get; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public string Stream => StreamOptions.Stream;
|
||||
|
||||
public string TenantId => Message.TenantId;
|
||||
|
||||
public string? PartitionKey => Message.PartitionKey;
|
||||
|
||||
public string IdempotencyKey => Message.IdempotencyKey;
|
||||
|
||||
public string? TraceId => Message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
|
||||
|
||||
public NotifyQueueEventMessage Message { get; }
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Documents;
|
||||
namespace StellaOps.Notify.Storage.InMemory.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a notification channel document (MongoDB compatibility shim).
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.InMemory.Documents;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
namespace StellaOps.Notify.Storage.InMemory.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for notification channels (MongoDB compatibility shim).
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.InMemory.Documents;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
namespace StellaOps.Notify.Storage.InMemory.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of channel repository for development/testing.
|
||||
@@ -1,24 +1,24 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notify.Storage.InMemory.Repositories;
|
||||
using StellaOps.Notify.Storage.Postgres;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo;
|
||||
namespace StellaOps.Notify.Storage.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring Notify MongoDB compatibility shim.
|
||||
/// This shim delegates to PostgreSQL storage while maintaining the MongoDB interface.
|
||||
/// Extension methods for configuring Notify in-memory storage.
|
||||
/// This implementation delegates to PostgreSQL storage while maintaining the repository interface.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Notify MongoDB compatibility storage services.
|
||||
/// Adds Notify in-memory storage services.
|
||||
/// Internally delegates to PostgreSQL storage.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration section for storage options.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddNotifyMongoStorage(
|
||||
public static IServiceCollection AddNotifyInMemoryStorage(
|
||||
this IServiceCollection services,
|
||||
IConfigurationSection configuration)
|
||||
{
|
||||
@@ -33,7 +33,7 @@ public static class ServiceCollectionExtensions
|
||||
// Register the underlying Postgres storage
|
||||
services.AddNotifyPostgresStorageInternal(configuration);
|
||||
|
||||
// Register MongoDB-compatible repository adapters
|
||||
// Register in-memory repository adapters
|
||||
services.AddScoped<INotifyChannelRepository, NotifyChannelRepositoryAdapter>();
|
||||
services.AddScoped<INotifyRuleRepository, NotifyRuleRepositoryAdapter>();
|
||||
services.AddScoped<INotifyTemplateRepository, NotifyTemplateRepositoryAdapter>();
|
||||
@@ -6,8 +6,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Notify.Storage.Mongo</RootNamespace>
|
||||
<Description>MongoDB compatibility shim for Notify storage - delegates to PostgreSQL storage</Description>
|
||||
<RootNamespace>StellaOps.Notify.Storage.InMemory</RootNamespace>
|
||||
<Description>In-memory storage implementation for Notify - delegates to PostgreSQL storage</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -1,16 +1,16 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo;
|
||||
namespace StellaOps.Notify.Storage.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service for MongoDB initialization (compatibility shim - no-op).
|
||||
/// Hosted service for storage initialization (compatibility shim - no-op).
|
||||
/// </summary>
|
||||
public sealed class MongoInitializationHostedService : IHostedService
|
||||
public sealed class StorageInitializationHostedService : IHostedService
|
||||
{
|
||||
private readonly ILogger<MongoInitializationHostedService> _logger;
|
||||
private readonly ILogger<StorageInitializationHostedService> _logger;
|
||||
|
||||
public MongoInitializationHostedService(ILogger<MongoInitializationHostedService> logger)
|
||||
public StorageInitializationHostedService(ILogger<StorageInitializationHostedService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
Reference in New Issue
Block a user