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

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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,
}

View File

@@ -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
}

View File

@@ -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();
}
}

View File

@@ -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";
}

View File

@@ -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; } = [];
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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();
}
}

View File

@@ -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());
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}
}