Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Models — Agent Charter
## Mission
Define Notify DTOs and contracts per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

@@ -0,0 +1,637 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace StellaOps.Notify.Models;
/// <summary>
/// Deterministic JSON serializer tuned for Notify canonical documents.
/// </summary>
public static class NotifyCanonicalJsonSerializer
{
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false, useDeterministicResolver: true);
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true, useDeterministicResolver: true);
private static readonly JsonSerializerOptions ReadOptions = CreateOptions(writeIndented: false, useDeterministicResolver: false);
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrderOverrides = new Dictionary<Type, string[]>
{
{
typeof(NotifyRule),
new[]
{
"schemaVersion",
"ruleId",
"tenantId",
"name",
"description",
"enabled",
"match",
"actions",
"labels",
"metadata",
"createdBy",
"createdAt",
"updatedBy",
"updatedAt",
}
},
{
typeof(NotifyRuleMatch),
new[]
{
"eventKinds",
"namespaces",
"repositories",
"digests",
"labels",
"componentPurls",
"minSeverity",
"verdicts",
"kevOnly",
"vex",
}
},
{
typeof(NotifyRuleAction),
new[]
{
"actionId",
"channel",
"template",
"locale",
"digest",
"throttle",
"metadata",
"enabled",
}
},
{
typeof(NotifyChannel),
new[]
{
"schemaVersion",
"channelId",
"tenantId",
"name",
"type",
"displayName",
"description",
"config",
"enabled",
"labels",
"metadata",
"createdBy",
"createdAt",
"updatedBy",
"updatedAt",
}
},
{
typeof(NotifyChannelConfig),
new[]
{
"secretRef",
"target",
"endpoint",
"properties",
"limits",
}
},
{
typeof(NotifyTemplate),
new[]
{
"schemaVersion",
"templateId",
"tenantId",
"channelType",
"key",
"locale",
"description",
"renderMode",
"body",
"format",
"metadata",
"createdBy",
"createdAt",
"updatedBy",
"updatedAt",
}
},
{
typeof(NotifyEvent),
new[]
{
"eventId",
"kind",
"version",
"tenant",
"ts",
"actor",
"scope",
"payload",
"attributes",
}
},
{
typeof(NotifyEventScope),
new[]
{
"namespace",
"repo",
"digest",
"component",
"image",
"labels",
"attributes",
}
},
{
typeof(NotifyDelivery),
new[]
{
"deliveryId",
"tenantId",
"ruleId",
"actionId",
"eventId",
"kind",
"status",
"statusReason",
"createdAt",
"sentAt",
"completedAt",
"rendered",
"attempts",
"metadata",
}
},
{
typeof(NotifyDeliveryAttempt),
new[]
{
"timestamp",
"status",
"statusCode",
"reason",
}
},
{
typeof(NotifyDeliveryRendered),
new[]
{
"title",
"summary",
"target",
"locale",
"channelType",
"format",
"body",
"textBody",
"bodyHash",
"attachments",
}
},
};
public static string Serialize<T>(T value)
=> JsonSerializer.Serialize(value, CompactOptions);
public static string SerializeIndented<T>(T value)
=> JsonSerializer.Serialize(value, PrettyOptions);
public static T Deserialize<T>(string json)
{
if (typeof(T) == typeof(NotifyRule))
{
var dto = JsonSerializer.Deserialize<NotifyRuleDto>(json, ReadOptions)
?? throw new InvalidOperationException("Unable to deserialize NotifyRule payload.");
return (T)(object)dto.ToModel();
}
if (typeof(T) == typeof(NotifyChannel))
{
var dto = JsonSerializer.Deserialize<NotifyChannelDto>(json, ReadOptions)
?? throw new InvalidOperationException("Unable to deserialize NotifyChannel payload.");
return (T)(object)dto.ToModel();
}
if (typeof(T) == typeof(NotifyTemplate))
{
var dto = JsonSerializer.Deserialize<NotifyTemplateDto>(json, ReadOptions)
?? throw new InvalidOperationException("Unable to deserialize NotifyTemplate payload.");
return (T)(object)dto.ToModel();
}
if (typeof(T) == typeof(NotifyEvent))
{
var dto = JsonSerializer.Deserialize<NotifyEventDto>(json, ReadOptions)
?? throw new InvalidOperationException("Unable to deserialize NotifyEvent payload.");
return (T)(object)dto.ToModel();
}
if (typeof(T) == typeof(NotifyDelivery))
{
var dto = JsonSerializer.Deserialize<NotifyDeliveryDto>(json, ReadOptions)
?? throw new InvalidOperationException("Unable to deserialize NotifyDelivery payload.");
return (T)(object)dto.ToModel();
}
return JsonSerializer.Deserialize<T>(json, ReadOptions)
?? throw new InvalidOperationException($"Unable to deserialize type {typeof(T).Name}.");
}
private static JsonSerializerOptions CreateOptions(bool writeIndented, bool useDeterministicResolver)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = writeIndented,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
if (useDeterministicResolver)
{
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
}
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
options.Converters.Add(new Iso8601DurationConverter());
return options;
}
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
{
private readonly IJsonTypeInfoResolver _inner;
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = _inner.GetTypeInfo(type, options)
?? throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
{
var ordered = info.Properties
.OrderBy(property => GetPropertyOrder(type, property.Name))
.ThenBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
info.Properties.Clear();
foreach (var property in ordered)
{
info.Properties.Add(property);
}
}
return info;
}
private static int GetPropertyOrder(Type type, string propertyName)
{
if (PropertyOrderOverrides.TryGetValue(type, out var order) && Array.IndexOf(order, propertyName) is { } index and >= 0)
{
return index;
}
return int.MaxValue;
}
}
}
internal sealed class NotifyRuleDto
{
public string? SchemaVersion { get; set; }
public string? RuleId { get; set; }
public string? TenantId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public bool? Enabled { get; set; }
public NotifyRuleMatchDto? Match { get; set; }
public List<NotifyRuleActionDto>? Actions { get; set; }
public Dictionary<string, string>? Labels { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public string? UpdatedBy { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public NotifyRule ToModel()
=> NotifyRule.Create(
RuleId ?? throw new InvalidOperationException("ruleId missing"),
TenantId ?? throw new InvalidOperationException("tenantId missing"),
Name ?? throw new InvalidOperationException("name missing"),
(Match ?? new NotifyRuleMatchDto()).ToModel(),
Actions?.Select(action => action.ToModel()) ?? Array.Empty<NotifyRuleAction>(),
Enabled.GetValueOrDefault(true),
Description,
Labels,
Metadata,
CreatedBy,
CreatedAt,
UpdatedBy,
UpdatedAt,
SchemaVersion);
}
internal sealed class NotifyRuleMatchDto
{
public List<string>? EventKinds { get; set; }
public List<string>? Namespaces { get; set; }
public List<string>? Repositories { get; set; }
public List<string>? Digests { get; set; }
public List<string>? Labels { get; set; }
public List<string>? ComponentPurls { get; set; }
public string? MinSeverity { get; set; }
public List<string>? Verdicts { get; set; }
public bool? KevOnly { get; set; }
public NotifyRuleMatchVexDto? Vex { get; set; }
public NotifyRuleMatch ToModel()
=> NotifyRuleMatch.Create(
EventKinds,
Namespaces,
Repositories,
Digests,
Labels,
ComponentPurls,
MinSeverity,
Verdicts,
KevOnly,
Vex?.ToModel());
}
internal sealed class NotifyRuleMatchVexDto
{
public bool IncludeAcceptedJustifications { get; set; } = true;
public bool IncludeRejectedJustifications { get; set; }
public bool IncludeUnknownJustifications { get; set; }
public List<string>? JustificationKinds { get; set; }
public NotifyRuleMatchVex ToModel()
=> NotifyRuleMatchVex.Create(
IncludeAcceptedJustifications,
IncludeRejectedJustifications,
IncludeUnknownJustifications,
JustificationKinds);
}
internal sealed class NotifyRuleActionDto
{
public string? ActionId { get; set; }
public string? Channel { get; set; }
public string? Template { get; set; }
public string? Digest { get; set; }
public TimeSpan? Throttle { get; set; }
public string? Locale { get; set; }
public bool? Enabled { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
public NotifyRuleAction ToModel()
=> NotifyRuleAction.Create(
ActionId ?? throw new InvalidOperationException("actionId missing"),
Channel ?? throw new InvalidOperationException("channel missing"),
Template,
Digest,
Throttle,
Locale,
Enabled.GetValueOrDefault(true),
Metadata);
}
internal sealed class NotifyChannelDto
{
public string? SchemaVersion { get; set; }
public string? ChannelId { get; set; }
public string? TenantId { get; set; }
public string? Name { get; set; }
public NotifyChannelType Type { get; set; }
public NotifyChannelConfigDto? Config { get; set; }
public string? DisplayName { get; set; }
public string? Description { get; set; }
public bool? Enabled { get; set; }
public Dictionary<string, string>? Labels { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public string? UpdatedBy { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public NotifyChannel ToModel()
=> NotifyChannel.Create(
ChannelId ?? throw new InvalidOperationException("channelId missing"),
TenantId ?? throw new InvalidOperationException("tenantId missing"),
Name ?? throw new InvalidOperationException("name missing"),
Type,
(Config ?? new NotifyChannelConfigDto()).ToModel(),
DisplayName,
Description,
Enabled.GetValueOrDefault(true),
Labels,
Metadata,
CreatedBy,
CreatedAt,
UpdatedBy,
UpdatedAt,
SchemaVersion);
}
internal sealed class NotifyChannelConfigDto
{
public string? SecretRef { get; set; }
public string? Target { get; set; }
public string? Endpoint { get; set; }
public Dictionary<string, string>? Properties { get; set; }
public NotifyChannelLimitsDto? Limits { get; set; }
public NotifyChannelConfig ToModel()
=> NotifyChannelConfig.Create(
SecretRef ?? throw new InvalidOperationException("secretRef missing"),
Target,
Endpoint,
Properties,
Limits?.ToModel());
}
internal sealed class NotifyChannelLimitsDto
{
public int? Concurrency { get; set; }
public int? RequestsPerMinute { get; set; }
public TimeSpan? Timeout { get; set; }
public int? MaxBatchSize { get; set; }
public NotifyChannelLimits ToModel()
=> new(
Concurrency,
RequestsPerMinute,
Timeout,
MaxBatchSize);
}
internal sealed class NotifyTemplateDto
{
public string? SchemaVersion { get; set; }
public string? TemplateId { get; set; }
public string? TenantId { get; set; }
public NotifyChannelType ChannelType { get; set; }
public string? Key { get; set; }
public string? Locale { get; set; }
public string? Body { get; set; }
public NotifyTemplateRenderMode RenderMode { get; set; } = NotifyTemplateRenderMode.Markdown;
public NotifyDeliveryFormat Format { get; set; } = NotifyDeliveryFormat.Json;
public string? Description { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public string? UpdatedBy { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public NotifyTemplate ToModel()
=> NotifyTemplate.Create(
TemplateId ?? throw new InvalidOperationException("templateId missing"),
TenantId ?? throw new InvalidOperationException("tenantId missing"),
ChannelType,
Key ?? throw new InvalidOperationException("key missing"),
Locale ?? throw new InvalidOperationException("locale missing"),
Body ?? throw new InvalidOperationException("body missing"),
RenderMode,
Format,
Description,
Metadata,
CreatedBy,
CreatedAt,
UpdatedBy,
UpdatedAt,
SchemaVersion);
}
internal sealed class NotifyEventDto
{
public Guid EventId { get; set; }
public string? Kind { get; set; }
public string? Tenant { get; set; }
public DateTimeOffset Ts { get; set; }
public JsonNode? Payload { get; set; }
public NotifyEventScopeDto? Scope { get; set; }
public string? Version { get; set; }
public string? Actor { get; set; }
public Dictionary<string, string>? Attributes { get; set; }
public NotifyEvent ToModel()
=> NotifyEvent.Create(
EventId,
Kind ?? throw new InvalidOperationException("kind missing"),
Tenant ?? throw new InvalidOperationException("tenant missing"),
Ts,
Payload,
Scope?.ToModel(),
Version,
Actor,
Attributes);
}
internal sealed class NotifyEventScopeDto
{
public string? Namespace { get; set; }
public string? Repo { get; set; }
public string? Digest { get; set; }
public string? Component { get; set; }
public string? Image { get; set; }
public Dictionary<string, string>? Labels { get; set; }
public Dictionary<string, string>? Attributes { get; set; }
public NotifyEventScope ToModel()
=> NotifyEventScope.Create(
Namespace,
Repo,
Digest,
Component,
Image,
Labels,
Attributes);
}
internal sealed class NotifyDeliveryDto
{
public string? DeliveryId { get; set; }
public string? TenantId { get; set; }
public string? RuleId { get; set; }
public string? ActionId { get; set; }
public Guid EventId { get; set; }
public string? Kind { get; set; }
public NotifyDeliveryStatus Status { get; set; }
public string? StatusReason { get; set; }
public NotifyDeliveryRenderedDto? Rendered { get; set; }
public List<NotifyDeliveryAttemptDto>? Attempts { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public DateTimeOffset? SentAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public NotifyDelivery ToModel()
=> NotifyDelivery.Create(
DeliveryId ?? throw new InvalidOperationException("deliveryId missing"),
TenantId ?? throw new InvalidOperationException("tenantId missing"),
RuleId ?? throw new InvalidOperationException("ruleId missing"),
ActionId ?? throw new InvalidOperationException("actionId missing"),
EventId,
Kind ?? throw new InvalidOperationException("kind missing"),
Status,
StatusReason,
Rendered?.ToModel(),
Attempts?.Select(attempt => attempt.ToModel()),
Metadata,
CreatedAt,
SentAt,
CompletedAt);
}
internal sealed class NotifyDeliveryAttemptDto
{
public DateTimeOffset Timestamp { get; set; }
public NotifyDeliveryAttemptStatus Status { get; set; }
public int? StatusCode { get; set; }
public string? Reason { get; set; }
public NotifyDeliveryAttempt ToModel()
=> new(Timestamp, Status, StatusCode, Reason);
}
internal sealed class NotifyDeliveryRenderedDto
{
public NotifyChannelType ChannelType { get; set; }
public NotifyDeliveryFormat Format { get; set; }
public string? Target { get; set; }
public string? Title { get; set; }
public string? Body { get; set; }
public string? Summary { get; set; }
public string? TextBody { get; set; }
public string? Locale { get; set; }
public string? BodyHash { get; set; }
public List<string>? Attachments { get; set; }
public NotifyDeliveryRendered ToModel()
=> NotifyDeliveryRendered.Create(
ChannelType,
Format,
Target ?? throw new InvalidOperationException("target missing"),
Title ?? throw new InvalidOperationException("title missing"),
Body ?? throw new InvalidOperationException("body missing"),
Summary,
TextBody,
Locale,
BodyHash,
Attachments);
}

View File

@@ -0,0 +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; }
}

View File

@@ -0,0 +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; }
}

View File

@@ -0,0 +1,70 @@
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,
}
/// <summary>
/// Delivery lifecycle states tracked for audit and retries.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyDeliveryStatus
{
Pending,
Sent,
Failed,
Throttled,
Digested,
Dropped,
}
/// <summary>
/// Individual attempt status recorded during delivery retries.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyDeliveryAttemptStatus
{
Enqueued,
Sending,
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
{
Slack,
Teams,
Email,
Webhook,
Json,
}

View File

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

View File

@@ -0,0 +1,15 @@
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 FeedserExportCompleted = "feedser.export.completed";
public const string VexerExportCompleted = "vexer.export.completed";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,2 @@
# Notify Models Task Board (Sprint 15)
> Archived 2025-10-26 — scope moved to `src/Notifier/StellaOps.Notifier` (Sprints 3840).