up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -13,6 +13,10 @@ public enum NotifyChannelType
Email,
Webhook,
Custom,
PagerDuty,
OpsGenie,
Cli,
InAppInbox,
}
/// <summary>
@@ -67,4 +71,8 @@ public enum NotifyDeliveryFormat
Email,
Webhook,
Json,
PagerDuty,
OpsGenie,
Cli,
InAppInbox,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,16 +16,34 @@ public sealed class NotifyMongoOptions
public string DeliveriesCollection { get; set; } = "deliveries";
public string DigestsCollection { get; set; } = "digests";
public string PackApprovalsCollection { get; set; } = "pack_approvals";
public string LocksCollection { get; set; } = "locks";
public string DigestsCollection { get; set; } = "digests";
public string PackApprovalsCollection { get; set; } = "pack_approvals";
public string LocksCollection { get; set; } = "locks";
public string AuditCollection { get; set; } = "audit";
public string MigrationsCollection { get; set; } = "_notify_migrations";
public string QuietHoursCollection { get; set; } = "quiet_hours";
public string MaintenanceWindowsCollection { get; set; } = "maintenance_windows";
public string ThrottleConfigsCollection { get; set; } = "throttle_configs";
public string OperatorOverridesCollection { get; set; } = "operator_overrides";
public string EscalationPoliciesCollection { get; set; } = "escalation_policies";
public string EscalationStatesCollection { get; set; } = "escalation_states";
public string OnCallSchedulesCollection { get; set; } = "oncall_schedules";
public string InboxCollection { get; set; } = "inbox";
public string LocalizationCollection { get; set; } = "localization";
public TimeSpan DeliveryHistoryRetention { get; set; } = TimeSpan.FromDays(90);
public bool UseMajorityReadConcern { get; set; } = true;

View File

@@ -0,0 +1,40 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository for managing escalation policies.
/// </summary>
public interface INotifyEscalationPolicyRepository
{
/// <summary>
/// Gets an escalation policy by ID.
/// </summary>
Task<NotifyEscalationPolicy?> GetAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all escalation policies for a tenant.
/// </summary>
Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
string tenantId,
bool? enabledOnly = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates an escalation policy.
/// </summary>
Task UpsertAsync(
NotifyEscalationPolicy policy,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an escalation policy.
/// </summary>
Task DeleteAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,90 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository for managing escalation state.
/// </summary>
public interface INotifyEscalationStateRepository
{
/// <summary>
/// Gets escalation state by ID.
/// </summary>
Task<NotifyEscalationState?> GetAsync(
string tenantId,
string stateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets escalation state for an incident.
/// </summary>
Task<NotifyEscalationState?> GetByIncidentAsync(
string tenantId,
string incidentId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists active escalation states due for processing.
/// </summary>
Task<IReadOnlyList<NotifyEscalationState>> ListDueForEscalationAsync(
string tenantId,
DateTimeOffset now,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all escalation states for a tenant.
/// </summary>
Task<IReadOnlyList<NotifyEscalationState>> ListAsync(
string tenantId,
NotifyEscalationStatus? status = null,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates an escalation state.
/// </summary>
Task UpsertAsync(
NotifyEscalationState state,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the escalation state after a level transition.
/// </summary>
Task UpdateLevelAsync(
string tenantId,
string stateId,
int newLevel,
int newIteration,
DateTimeOffset? nextEscalationAt,
NotifyEscalationAttempt attempt,
CancellationToken cancellationToken = default);
/// <summary>
/// Acknowledges an escalation.
/// </summary>
Task AcknowledgeAsync(
string tenantId,
string stateId,
string acknowledgedBy,
DateTimeOffset acknowledgedAt,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves an escalation.
/// </summary>
Task ResolveAsync(
string tenantId,
string stateId,
string resolvedBy,
DateTimeOffset resolvedAt,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an escalation state.
/// </summary>
Task DeleteAsync(
string tenantId,
string stateId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,39 @@
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository interface for in-app inbox message storage.
/// </summary>
public interface INotifyInboxRepository
{
Task<string> StoreAsync(NotifyInboxMessage message, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default);
Task<NotifyInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
Task DeleteExpiredAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// In-app inbox message model for storage.
/// </summary>
public sealed record NotifyInboxMessage
{
public required string MessageId { get; init; }
public required string TenantId { get; init; }
public required string UserId { get; init; }
public required string Title { get; init; }
public required string Body { get; init; }
public string? Summary { get; init; }
public required string Category { get; init; }
public int Priority { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? ReadAt { get; set; }
public bool IsRead => ReadAt.HasValue;
public string? SourceChannel { get; init; }
public string? DeliveryId { get; init; }
public bool IsDeleted { get; set; }
}

View File

@@ -0,0 +1,65 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository for managing localization bundles.
/// </summary>
public interface INotifyLocalizationRepository
{
/// <summary>
/// Gets a localization bundle by ID.
/// </summary>
Task<NotifyLocalizationBundle?> GetAsync(
string tenantId,
string bundleId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a localization bundle by key and locale.
/// </summary>
Task<NotifyLocalizationBundle?> GetByKeyAndLocaleAsync(
string tenantId,
string bundleKey,
string locale,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all localization bundles for a tenant.
/// </summary>
Task<IReadOnlyList<NotifyLocalizationBundle>> ListAsync(
string tenantId,
string? bundleKey = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all locales available for a bundle key.
/// </summary>
Task<IReadOnlyList<string>> ListLocalesAsync(
string tenantId,
string bundleKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates a localization bundle.
/// </summary>
Task UpsertAsync(
NotifyLocalizationBundle bundle,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a localization bundle.
/// </summary>
Task DeleteAsync(
string tenantId,
string bundleId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the default bundle for a bundle key.
/// </summary>
Task<NotifyLocalizationBundle?> GetDefaultAsync(
string tenantId,
string bundleKey,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,41 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository for maintenance windows.
/// </summary>
public interface INotifyMaintenanceWindowRepository
{
/// <summary>
/// Inserts or updates a maintenance window.
/// </summary>
Task UpsertAsync(NotifyMaintenanceWindow window, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a maintenance window by ID.
/// </summary>
Task<NotifyMaintenanceWindow?> GetAsync(string tenantId, string windowId, CancellationToken cancellationToken = default);
/// <summary>
/// Lists all maintenance windows for a tenant.
/// </summary>
Task<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
string tenantId,
bool? activeOnly = null,
DateTimeOffset? asOf = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets active maintenance windows for a tenant at a specific point in time.
/// </summary>
Task<IReadOnlyList<NotifyMaintenanceWindow>> GetActiveAsync(
string tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a maintenance window.
/// </summary>
Task DeleteAsync(string tenantId, string windowId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,58 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository for managing on-call schedules.
/// </summary>
public interface INotifyOnCallScheduleRepository
{
/// <summary>
/// Gets an on-call schedule by ID.
/// </summary>
Task<NotifyOnCallSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all on-call schedules for a tenant.
/// </summary>
Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(
string tenantId,
bool? enabledOnly = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates an on-call schedule.
/// </summary>
Task UpsertAsync(
NotifyOnCallSchedule schedule,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds an override to a schedule.
/// </summary>
Task AddOverrideAsync(
string tenantId,
string scheduleId,
NotifyOnCallOverride override_,
CancellationToken cancellationToken = default);
/// <summary>
/// Removes an override from a schedule.
/// </summary>
Task RemoveOverrideAsync(
string tenantId,
string scheduleId,
string overrideId,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an on-call schedule.
/// </summary>
Task DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,49 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository for operator overrides.
/// </summary>
public interface INotifyOperatorOverrideRepository
{
/// <summary>
/// Inserts or updates an operator override.
/// </summary>
Task UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an operator override by ID.
/// </summary>
Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
/// <summary>
/// Lists all active operator overrides for a tenant.
/// </summary>
Task<IReadOnlyList<NotifyOperatorOverride>> ListActiveAsync(
string tenantId,
DateTimeOffset asOf,
NotifyOverrideType? overrideType = null,
string? channelId = null,
string? ruleId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all operator overrides for a tenant (including expired).
/// </summary>
Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(
string tenantId,
bool? activeOnly = null,
DateTimeOffset? asOf = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an operator override.
/// </summary>
Task DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes all expired operator overrides for a tenant.
/// </summary>
Task DeleteExpiredAsync(string tenantId, DateTimeOffset olderThan, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,41 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository for quiet hours schedules.
/// </summary>
public interface INotifyQuietHoursRepository
{
/// <summary>
/// Inserts or updates a quiet hours schedule.
/// </summary>
Task UpsertAsync(NotifyQuietHoursSchedule schedule, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a quiet hours schedule by ID.
/// </summary>
Task<NotifyQuietHoursSchedule?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default);
/// <summary>
/// Lists all quiet hours schedules for a tenant.
/// </summary>
Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
string tenantId,
string? channelId = null,
bool? enabledOnly = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all enabled quiet hours schedules for a tenant, optionally filtered by channel.
/// </summary>
Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListEnabledAsync(
string tenantId,
string? channelId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a quiet hours schedule.
/// </summary>
Task DeleteAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,41 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository for throttle configurations.
/// </summary>
public interface INotifyThrottleConfigRepository
{
/// <summary>
/// Inserts or updates a throttle configuration.
/// </summary>
Task UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a throttle configuration by ID.
/// </summary>
Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the default throttle configuration for a tenant.
/// </summary>
Task<NotifyThrottleConfig?> GetDefaultAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the throttle configuration for a specific channel.
/// </summary>
Task<NotifyThrottleConfig?> GetForChannelAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
/// <summary>
/// Lists all throttle configurations for a tenant.
/// </summary>
Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a throttle configuration.
/// </summary>
Task DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,191 @@
using System.Text.Json;
using System.Collections.Immutable;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// MongoDB implementation of escalation policy repository.
/// </summary>
internal sealed class NotifyEscalationPolicyRepository : INotifyEscalationPolicyRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
public NotifyEscalationPolicyRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<BsonDocument>(context.Options.EscalationPoliciesCollection);
}
public async Task<NotifyEscalationPolicy?> GetAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, policyId))
& NotDeletedFilter();
var doc = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc is null ? null : FromBsonDocument(doc);
}
public async Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
string tenantId,
bool? enabledOnly = null,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
if (enabledOnly == true)
{
filter &= Builders<BsonDocument>.Filter.Eq("enabled", true);
}
var docs = await _collection.Find(filter)
.Sort(Builders<BsonDocument>.Sort.Ascending("name"))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(FromBsonDocument).ToArray();
}
public async Task UpsertAsync(
NotifyEscalationPolicy policy,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(policy);
var doc = ToBsonDocument(policy);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(policy.TenantId, policy.PolicyId));
await _collection.ReplaceOneAsync(filter, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, policyId));
await _collection.UpdateOneAsync(
filter,
Builders<BsonDocument>.Update
.Set("deletedAt", DateTime.UtcNow)
.Set("enabled", false),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static FilterDefinition<BsonDocument> NotDeletedFilter()
=> Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
private static BsonDocument ToBsonDocument(NotifyEscalationPolicy policy)
{
var json = JsonSerializer.Serialize(policy, JsonOptions);
var document = BsonDocument.Parse(json);
document["_id"] = CreateDocumentId(policy.TenantId, policy.PolicyId);
// Convert level escalateAfter to ticks for storage
if (document.Contains("levels") && document["levels"].IsBsonArray)
{
foreach (var level in document["levels"].AsBsonArray)
{
if (level.IsBsonDocument && level.AsBsonDocument.Contains("escalateAfter"))
{
var escalateAfter = level.AsBsonDocument["escalateAfter"].AsString;
if (TimeSpan.TryParse(escalateAfter, out var ts))
{
level.AsBsonDocument["escalateAfterTicks"] = ts.Ticks;
}
}
}
}
return document;
}
private static NotifyEscalationPolicy FromBsonDocument(BsonDocument doc)
{
var levels = new List<NotifyEscalationLevel>();
if (doc.Contains("levels") && doc["levels"].IsBsonArray)
{
foreach (var levelVal in doc["levels"].AsBsonArray)
{
if (levelVal.IsBsonDocument)
{
levels.Add(LevelFromBson(levelVal.AsBsonDocument));
}
}
}
var metadata = ExtractStringDictionary(doc, "metadata");
return NotifyEscalationPolicy.Create(
policyId: doc["policyId"].AsString,
tenantId: doc["tenantId"].AsString,
name: doc["name"].AsString,
levels: levels,
enabled: doc.Contains("enabled") ? doc["enabled"].AsBoolean : true,
repeatEnabled: doc.Contains("repeatEnabled") ? doc["repeatEnabled"].AsBoolean : false,
repeatCount: doc.Contains("repeatCount") && doc["repeatCount"] != BsonNull.Value ? doc["repeatCount"].AsInt32 : null,
description: doc.Contains("description") && doc["description"] != BsonNull.Value ? doc["description"].AsString : null,
metadata: metadata,
createdBy: doc.Contains("createdBy") && doc["createdBy"] != BsonNull.Value ? doc["createdBy"].AsString : null,
createdAt: doc.Contains("createdAt") ? DateTimeOffset.Parse(doc["createdAt"].AsString) : null,
updatedBy: doc.Contains("updatedBy") && doc["updatedBy"] != BsonNull.Value ? doc["updatedBy"].AsString : null,
updatedAt: doc.Contains("updatedAt") ? DateTimeOffset.Parse(doc["updatedAt"].AsString) : null);
}
private static NotifyEscalationLevel LevelFromBson(BsonDocument doc)
{
var escalateAfter = doc.Contains("escalateAfterTicks")
? TimeSpan.FromTicks(doc["escalateAfterTicks"].AsInt64)
: TimeSpan.FromMinutes(15);
var targets = new List<NotifyEscalationTarget>();
if (doc.Contains("targets") && doc["targets"].IsBsonArray)
{
foreach (var targetVal in doc["targets"].AsBsonArray)
{
if (targetVal.IsBsonDocument)
{
var td = targetVal.AsBsonDocument;
targets.Add(NotifyEscalationTarget.Create(
Enum.Parse<NotifyEscalationTargetType>(td["type"].AsString),
td["targetId"].AsString,
td.Contains("channelOverride") && td["channelOverride"] != BsonNull.Value ? td["channelOverride"].AsString : null));
}
}
}
return NotifyEscalationLevel.Create(
order: doc["order"].AsInt32,
escalateAfter: escalateAfter,
targets: targets,
name: doc.Contains("name") && doc["name"] != BsonNull.Value ? doc["name"].AsString : null,
notifyAll: doc.Contains("notifyAll") ? doc["notifyAll"].AsBoolean : true);
}
private static IEnumerable<KeyValuePair<string, string>>? ExtractStringDictionary(BsonDocument document, string key)
{
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonDocument)
{
return null;
}
var dict = document[key].AsBsonDocument;
return dict.Elements.Select(e => new KeyValuePair<string, string>(e.Name, e.Value.AsString));
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> $"{tenantId}:{resourceId}";
}

View File

@@ -0,0 +1,298 @@
using System.Text.Json;
using System.Collections.Immutable;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// MongoDB implementation of escalation state repository.
/// </summary>
internal sealed class NotifyEscalationStateRepository : INotifyEscalationStateRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
public NotifyEscalationStateRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<BsonDocument>(context.Options.EscalationStatesCollection);
}
public async Task<NotifyEscalationState?> GetAsync(
string tenantId,
string stateId,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, stateId));
var doc = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc is null ? null : FromBsonDocument(doc);
}
public async Task<NotifyEscalationState?> GetByIncidentAsync(
string tenantId,
string incidentId,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Eq("incidentId", incidentId));
var doc = await _collection.Find(filter)
.Sort(Builders<BsonDocument>.Sort.Descending("createdAt"))
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return doc is null ? null : FromBsonDocument(doc);
}
public async Task<IReadOnlyList<NotifyEscalationState>> ListDueForEscalationAsync(
string tenantId,
DateTimeOffset now,
int limit = 100,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Eq("status", NotifyEscalationStatus.Active.ToString()),
Builders<BsonDocument>.Filter.Lte("nextEscalationAt", now.UtcDateTime));
var docs = await _collection.Find(filter)
.Sort(Builders<BsonDocument>.Sort.Ascending("nextEscalationAt"))
.Limit(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(FromBsonDocument).ToArray();
}
public async Task<IReadOnlyList<NotifyEscalationState>> ListAsync(
string tenantId,
NotifyEscalationStatus? status = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId);
if (status.HasValue)
{
filter &= Builders<BsonDocument>.Filter.Eq("status", status.Value.ToString());
}
var docs = await _collection.Find(filter)
.Sort(Builders<BsonDocument>.Sort.Descending("createdAt"))
.Limit(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(FromBsonDocument).ToArray();
}
public async Task UpsertAsync(
NotifyEscalationState state,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(state);
var doc = ToBsonDocument(state);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(state.TenantId, state.StateId));
await _collection.ReplaceOneAsync(filter, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task UpdateLevelAsync(
string tenantId,
string stateId,
int newLevel,
int newIteration,
DateTimeOffset? nextEscalationAt,
NotifyEscalationAttempt attempt,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, stateId));
var attemptDoc = AttemptToBson(attempt);
var updateBuilder = Builders<BsonDocument>.Update;
var updates = new List<UpdateDefinition<BsonDocument>>
{
updateBuilder.Set("currentLevel", newLevel),
updateBuilder.Set("repeatIteration", newIteration),
updateBuilder.Set("updatedAt", DateTime.UtcNow),
updateBuilder.Push("attempts", attemptDoc)
};
if (nextEscalationAt.HasValue)
{
updates.Add(updateBuilder.Set("nextEscalationAt", nextEscalationAt.Value.UtcDateTime));
}
else
{
updates.Add(updateBuilder.Unset("nextEscalationAt"));
}
var update = updateBuilder.Combine(updates);
await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task AcknowledgeAsync(
string tenantId,
string stateId,
string acknowledgedBy,
DateTimeOffset acknowledgedAt,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, stateId));
var update = Builders<BsonDocument>.Update
.Set("status", NotifyEscalationStatus.Acknowledged.ToString())
.Set("acknowledgedBy", acknowledgedBy)
.Set("acknowledgedAt", acknowledgedAt.UtcDateTime)
.Set("updatedAt", DateTime.UtcNow)
.Unset("nextEscalationAt");
await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task ResolveAsync(
string tenantId,
string stateId,
string resolvedBy,
DateTimeOffset resolvedAt,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, stateId));
var update = Builders<BsonDocument>.Update
.Set("status", NotifyEscalationStatus.Resolved.ToString())
.Set("resolvedBy", resolvedBy)
.Set("resolvedAt", resolvedAt.UtcDateTime)
.Set("updatedAt", DateTime.UtcNow)
.Unset("nextEscalationAt");
await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAsync(
string tenantId,
string stateId,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, stateId));
await _collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
}
private static BsonDocument ToBsonDocument(NotifyEscalationState state)
{
var doc = new BsonDocument
{
["_id"] = CreateDocumentId(state.TenantId, state.StateId),
["stateId"] = state.StateId,
["tenantId"] = state.TenantId,
["incidentId"] = state.IncidentId,
["policyId"] = state.PolicyId,
["currentLevel"] = state.CurrentLevel,
["repeatIteration"] = state.RepeatIteration,
["status"] = state.Status.ToString(),
["createdAt"] = state.CreatedAt.UtcDateTime,
["updatedAt"] = state.UpdatedAt.UtcDateTime
};
if (state.NextEscalationAt.HasValue)
doc["nextEscalationAt"] = state.NextEscalationAt.Value.UtcDateTime;
if (state.AcknowledgedAt.HasValue)
doc["acknowledgedAt"] = state.AcknowledgedAt.Value.UtcDateTime;
if (state.AcknowledgedBy is not null)
doc["acknowledgedBy"] = state.AcknowledgedBy;
if (state.ResolvedAt.HasValue)
doc["resolvedAt"] = state.ResolvedAt.Value.UtcDateTime;
if (state.ResolvedBy is not null)
doc["resolvedBy"] = state.ResolvedBy;
var attempts = new BsonArray();
foreach (var attempt in state.Attempts)
{
attempts.Add(AttemptToBson(attempt));
}
doc["attempts"] = attempts;
return doc;
}
private static BsonDocument AttemptToBson(NotifyEscalationAttempt attempt)
{
var doc = new BsonDocument
{
["level"] = attempt.Level,
["iteration"] = attempt.Iteration,
["timestamp"] = attempt.Timestamp.UtcDateTime,
["success"] = attempt.Success,
["notifiedTargets"] = new BsonArray(attempt.NotifiedTargets)
};
if (attempt.FailureReason is not null)
doc["failureReason"] = attempt.FailureReason;
return doc;
}
private static NotifyEscalationState FromBsonDocument(BsonDocument doc)
{
var attempts = new List<NotifyEscalationAttempt>();
if (doc.Contains("attempts") && doc["attempts"].IsBsonArray)
{
foreach (var attemptVal in doc["attempts"].AsBsonArray)
{
if (attemptVal.IsBsonDocument)
{
var ad = attemptVal.AsBsonDocument;
attempts.Add(new NotifyEscalationAttempt(
level: ad["level"].AsInt32,
iteration: ad["iteration"].AsInt32,
timestamp: new DateTimeOffset(ad["timestamp"].ToUniversalTime(), TimeSpan.Zero),
notifiedTargets: ad.GetValue("notifiedTargets", new BsonArray()).AsBsonArray
.Select(t => t.AsString)
.ToImmutableArray(),
success: ad["success"].AsBoolean,
failureReason: ad.Contains("failureReason") && ad["failureReason"] != BsonNull.Value
? ad["failureReason"].AsString
: null));
}
}
}
return NotifyEscalationState.Create(
stateId: doc["stateId"].AsString,
tenantId: doc["tenantId"].AsString,
incidentId: doc["incidentId"].AsString,
policyId: doc["policyId"].AsString,
currentLevel: doc["currentLevel"].AsInt32,
repeatIteration: doc["repeatIteration"].AsInt32,
status: Enum.Parse<NotifyEscalationStatus>(doc["status"].AsString),
attempts: attempts,
nextEscalationAt: doc.Contains("nextEscalationAt") && doc["nextEscalationAt"] != BsonNull.Value
? new DateTimeOffset(doc["nextEscalationAt"].ToUniversalTime(), TimeSpan.Zero)
: null,
createdAt: new DateTimeOffset(doc["createdAt"].ToUniversalTime(), TimeSpan.Zero),
updatedAt: new DateTimeOffset(doc["updatedAt"].ToUniversalTime(), TimeSpan.Zero),
acknowledgedAt: doc.Contains("acknowledgedAt") && doc["acknowledgedAt"] != BsonNull.Value
? new DateTimeOffset(doc["acknowledgedAt"].ToUniversalTime(), TimeSpan.Zero)
: null,
acknowledgedBy: doc.Contains("acknowledgedBy") && doc["acknowledgedBy"] != BsonNull.Value
? doc["acknowledgedBy"].AsString
: null,
resolvedAt: doc.Contains("resolvedAt") && doc["resolvedAt"] != BsonNull.Value
? new DateTimeOffset(doc["resolvedAt"].ToUniversalTime(), TimeSpan.Zero)
: null,
resolvedBy: doc.Contains("resolvedBy") && doc["resolvedBy"] != BsonNull.Value
? doc["resolvedBy"].AsString
: null);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> $"{tenantId}:{resourceId}";
}

View File

@@ -0,0 +1,213 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// MongoDB implementation of in-app inbox message storage.
/// </summary>
internal sealed class NotifyInboxRepository : INotifyInboxRepository
{
private readonly NotifyMongoContext _context;
private readonly string _collectionName;
public NotifyInboxRepository(NotifyMongoContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_collectionName = _context.Options.InboxCollection;
}
private IMongoCollection<BsonDocument> GetCollection() =>
_context.Database.GetCollection<BsonDocument>(_collectionName);
public async Task<string> StoreAsync(NotifyInboxMessage message, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
var doc = new BsonDocument
{
["_id"] = message.MessageId,
["tenantId"] = message.TenantId,
["userId"] = message.UserId,
["title"] = message.Title,
["body"] = message.Body,
["summary"] = message.Summary is not null ? (BsonValue)message.Summary : BsonNull.Value,
["category"] = message.Category,
["priority"] = message.Priority,
["metadata"] = message.Metadata is not null
? new BsonDocument(message.Metadata.ToDictionary(kv => kv.Key, kv => (BsonValue)kv.Value))
: BsonNull.Value,
["createdAt"] = message.CreatedAt.UtcDateTime,
["expiresAt"] = message.ExpiresAt.HasValue ? (BsonValue)message.ExpiresAt.Value.UtcDateTime : BsonNull.Value,
["readAt"] = BsonNull.Value,
["sourceChannel"] = message.SourceChannel is not null ? (BsonValue)message.SourceChannel : BsonNull.Value,
["deliveryId"] = message.DeliveryId is not null ? (BsonValue)message.DeliveryId : BsonNull.Value,
["isDeleted"] = false
};
await GetCollection().InsertOneAsync(doc, cancellationToken: cancellationToken).ConfigureAwait(false);
return message.MessageId;
}
public async Task<IReadOnlyList<NotifyInboxMessage>> GetForUserAsync(
string tenantId,
string userId,
int limit = 50,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Eq("userId", userId),
Builders<BsonDocument>.Filter.Ne("isDeleted", true),
Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Eq("expiresAt", BsonNull.Value),
Builders<BsonDocument>.Filter.Gt("expiresAt", DateTime.UtcNow)
)
);
var sort = Builders<BsonDocument>.Sort.Descending("createdAt");
var docs = await GetCollection()
.Find(filter)
.Sort(sort)
.Limit(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(MapFromBson).ToList();
}
public async Task<NotifyInboxMessage?> GetAsync(
string tenantId,
string messageId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(messageId);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", messageId),
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Ne("isDeleted", true)
);
var doc = await GetCollection()
.Find(filter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return doc is null ? null : MapFromBson(doc);
}
public async Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(messageId);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", messageId),
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
);
var update = Builders<BsonDocument>.Update.Set("readAt", DateTime.UtcNow);
await GetCollection().UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Eq("userId", userId),
Builders<BsonDocument>.Filter.Eq("readAt", BsonNull.Value)
);
var update = Builders<BsonDocument>.Update.Set("readAt", DateTime.UtcNow);
await GetCollection().UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(messageId);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", messageId),
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
);
var update = Builders<BsonDocument>.Update.Set("isDeleted", true);
await GetCollection().UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Eq("userId", userId),
Builders<BsonDocument>.Filter.Eq("readAt", BsonNull.Value),
Builders<BsonDocument>.Filter.Ne("isDeleted", true),
Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Eq("expiresAt", BsonNull.Value),
Builders<BsonDocument>.Filter.Gt("expiresAt", DateTime.UtcNow)
)
);
var count = await GetCollection().CountDocumentsAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
return (int)count;
}
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Lt("expiresAt", DateTime.UtcNow),
Builders<BsonDocument>.Filter.Ne("expiresAt", BsonNull.Value)
);
await GetCollection().DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
}
private static NotifyInboxMessage MapFromBson(BsonDocument doc)
{
return new NotifyInboxMessage
{
MessageId = doc["_id"].AsString,
TenantId = doc["tenantId"].AsString,
UserId = doc["userId"].AsString,
Title = doc["title"].AsString,
Body = doc["body"].AsString,
Summary = doc["summary"].IsBsonNull ? null : doc["summary"].AsString,
Category = doc["category"].AsString,
Priority = doc.Contains("priority") ? doc["priority"].AsInt32 : 0,
Metadata = doc["metadata"].IsBsonNull
? null
: doc["metadata"].AsBsonDocument.ToDictionary(e => e.Name, e => e.Value.AsString),
CreatedAt = new DateTimeOffset(doc["createdAt"].ToUniversalTime(), TimeSpan.Zero),
ExpiresAt = doc["expiresAt"].IsBsonNull
? null
: new DateTimeOffset(doc["expiresAt"].ToUniversalTime(), TimeSpan.Zero),
ReadAt = doc["readAt"].IsBsonNull
? null
: new DateTimeOffset(doc["readAt"].ToUniversalTime(), TimeSpan.Zero),
SourceChannel = doc.Contains("sourceChannel") && !doc["sourceChannel"].IsBsonNull
? doc["sourceChannel"].AsString
: null,
DeliveryId = doc.Contains("deliveryId") && !doc["deliveryId"].IsBsonNull
? doc["deliveryId"].AsString
: null,
IsDeleted = doc.Contains("isDeleted") && doc["isDeleted"].AsBoolean
};
}
}

View File

@@ -0,0 +1,222 @@
using System.Collections.Immutable;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Options;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// MongoDB implementation of the localization bundle repository.
/// </summary>
internal sealed class NotifyLocalizationRepository : INotifyLocalizationRepository
{
private readonly NotifyMongoContext _context;
private readonly string _collectionName;
public NotifyLocalizationRepository(NotifyMongoContext context, NotifyMongoOptions options)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_collectionName = options?.LocalizationCollection ?? "localization";
}
private IMongoCollection<BsonDocument> GetCollection()
=> _context.Database.GetCollection<BsonDocument>(_collectionName);
public async Task<NotifyLocalizationBundle?> GetAsync(
string tenantId,
string bundleId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Eq("bundleId", bundleId));
var doc = await GetCollection().Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc is null ? null : ToBundle(doc);
}
public async Task<NotifyLocalizationBundle?> GetByKeyAndLocaleAsync(
string tenantId,
string bundleKey,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentException.ThrowIfNullOrWhiteSpace(locale);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Eq("bundleKey", bundleKey),
Builders<BsonDocument>.Filter.Eq("locale", locale.ToLowerInvariant()));
var doc = await GetCollection().Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc is null ? null : ToBundle(doc);
}
public async Task<IReadOnlyList<NotifyLocalizationBundle>> ListAsync(
string tenantId,
string? bundleKey = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var filterBuilder = Builders<BsonDocument>.Filter;
var filters = new List<FilterDefinition<BsonDocument>>
{
filterBuilder.Eq("tenantId", tenantId)
};
if (!string.IsNullOrWhiteSpace(bundleKey))
{
filters.Add(filterBuilder.Eq("bundleKey", bundleKey));
}
var filter = filterBuilder.And(filters);
var docs = await GetCollection().Find(filter)
.Sort(Builders<BsonDocument>.Sort.Ascending("bundleKey").Ascending("locale"))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(ToBundle).ToArray();
}
public async Task<IReadOnlyList<string>> ListLocalesAsync(
string tenantId,
string bundleKey,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Eq("bundleKey", bundleKey));
var locales = await GetCollection()
.Distinct<string>("locale", filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return locales.OrderBy(l => l).ToArray();
}
public async Task UpsertAsync(
NotifyLocalizationBundle bundle,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
var doc = ToBsonDocument(bundle);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", bundle.TenantId),
Builders<BsonDocument>.Filter.Eq("bundleId", bundle.BundleId));
await GetCollection().ReplaceOneAsync(
filter,
doc,
new ReplaceOptions { IsUpsert = true },
cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAsync(
string tenantId,
string bundleId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Eq("bundleId", bundleId));
await GetCollection().DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyLocalizationBundle?> GetDefaultAsync(
string tenantId,
string bundleKey,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Eq("bundleKey", bundleKey),
Builders<BsonDocument>.Filter.Eq("isDefault", true));
var doc = await GetCollection().Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc is null ? null : ToBundle(doc);
}
private static BsonDocument ToBsonDocument(NotifyLocalizationBundle bundle)
{
var stringsDoc = new BsonDocument();
foreach (var (key, value) in bundle.Strings)
{
stringsDoc[key] = value;
}
var metadataDoc = new BsonDocument();
foreach (var (key, value) in bundle.Metadata)
{
metadataDoc[key] = value;
}
return new BsonDocument
{
["bundleId"] = bundle.BundleId,
["tenantId"] = bundle.TenantId,
["locale"] = bundle.Locale,
["bundleKey"] = bundle.BundleKey,
["strings"] = stringsDoc,
["isDefault"] = bundle.IsDefault,
["parentLocale"] = bundle.ParentLocale is not null ? (BsonValue)bundle.ParentLocale : BsonNull.Value,
["description"] = bundle.Description is not null ? (BsonValue)bundle.Description : BsonNull.Value,
["metadata"] = metadataDoc,
["createdBy"] = bundle.CreatedBy is not null ? (BsonValue)bundle.CreatedBy : BsonNull.Value,
["createdAt"] = bundle.CreatedAt.UtcDateTime,
["updatedBy"] = bundle.UpdatedBy is not null ? (BsonValue)bundle.UpdatedBy : BsonNull.Value,
["updatedAt"] = bundle.UpdatedAt.UtcDateTime
};
}
private static NotifyLocalizationBundle ToBundle(BsonDocument doc)
{
var stringsDoc = doc.GetValue("strings", new BsonDocument()).AsBsonDocument;
var strings = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var element in stringsDoc)
{
strings[element.Name] = element.Value.AsString;
}
var metadataDoc = doc.GetValue("metadata", new BsonDocument()).AsBsonDocument;
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var element in metadataDoc)
{
metadata[element.Name] = element.Value.AsString;
}
return new NotifyLocalizationBundle(
bundleId: doc["bundleId"].AsString,
tenantId: doc["tenantId"].AsString,
locale: doc["locale"].AsString,
bundleKey: doc["bundleKey"].AsString,
strings: strings.ToImmutable(),
isDefault: doc.GetValue("isDefault", false).AsBoolean,
parentLocale: doc.GetValue("parentLocale", BsonNull.Value).IsBsonNull ? null : doc["parentLocale"].AsString,
description: doc.GetValue("description", BsonNull.Value).IsBsonNull ? null : doc["description"].AsString,
metadata: metadata.ToImmutable(),
createdBy: doc.GetValue("createdBy", BsonNull.Value).IsBsonNull ? null : doc["createdBy"].AsString,
createdAt: new DateTimeOffset(doc.GetValue("createdAt", DateTime.UtcNow).ToUniversalTime(), TimeSpan.Zero),
updatedBy: doc.GetValue("updatedBy", BsonNull.Value).IsBsonNull ? null : doc["updatedBy"].AsString,
updatedAt: new DateTimeOffset(doc.GetValue("updatedAt", DateTime.UtcNow).ToUniversalTime(), TimeSpan.Zero));
}
}

View File

@@ -0,0 +1,147 @@
using System.Text.Json;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyMaintenanceWindowRepository : INotifyMaintenanceWindowRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
public NotifyMaintenanceWindowRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<BsonDocument>(context.Options.MaintenanceWindowsCollection);
}
public async Task UpsertAsync(NotifyMaintenanceWindow window, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(window);
var document = ToBsonDocument(window);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(window.TenantId, window.WindowId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyMaintenanceWindow?> GetAsync(string tenantId, string windowId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, windowId))
& NotDeletedFilter();
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : FromBsonDocument(document);
}
public async Task<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
string tenantId,
bool? activeOnly = null,
DateTimeOffset? asOf = null,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
if (activeOnly == true && asOf.HasValue)
{
var now = asOf.Value.UtcDateTime;
filter &= Builders<BsonDocument>.Filter.Lte("startsAt", now)
& Builders<BsonDocument>.Filter.Gt("endsAt", now)
& Builders<BsonDocument>.Filter.Eq("suppressNotifications", true);
}
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return cursor.Select(FromBsonDocument).ToArray();
}
public async Task<IReadOnlyList<NotifyMaintenanceWindow>> GetActiveAsync(
string tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
return await ListAsync(tenantId, activeOnly: true, asOf: asOf, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAsync(string tenantId, string windowId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, windowId));
await _collection.UpdateOneAsync(filter,
Builders<BsonDocument>.Update
.Set("deletedAt", DateTime.UtcNow)
.Set("suppressNotifications", false),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static FilterDefinition<BsonDocument> NotDeletedFilter()
=> Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
private static BsonDocument ToBsonDocument(NotifyMaintenanceWindow window)
{
var json = JsonSerializer.Serialize(window, JsonOptions);
var document = BsonDocument.Parse(json);
document["_id"] = BsonValue.Create(CreateDocumentId(window.TenantId, window.WindowId));
// Convert DateTimeOffset to DateTime for Mongo indexing
document["startsAt"] = window.StartsAt.UtcDateTime;
document["endsAt"] = window.EndsAt.UtcDateTime;
return document;
}
private static NotifyMaintenanceWindow FromBsonDocument(BsonDocument document)
{
var startsAt = document["startsAt"].ToUniversalTime();
var endsAt = document["endsAt"].ToUniversalTime();
return NotifyMaintenanceWindow.Create(
windowId: document["windowId"].AsString,
tenantId: document["tenantId"].AsString,
name: document["name"].AsString,
startsAt: new DateTimeOffset(startsAt, TimeSpan.Zero),
endsAt: new DateTimeOffset(endsAt, TimeSpan.Zero),
suppressNotifications: document.Contains("suppressNotifications") ? document["suppressNotifications"].AsBoolean : true,
reason: document.Contains("reason") && document["reason"] != BsonNull.Value ? document["reason"].AsString : null,
channelIds: ExtractStringArray(document, "channelIds"),
ruleIds: ExtractStringArray(document, "ruleIds"),
metadata: ExtractStringDictionary(document, "metadata"),
createdBy: document.Contains("createdBy") && document["createdBy"] != BsonNull.Value ? document["createdBy"].AsString : null,
createdAt: document.Contains("createdAt") ? DateTimeOffset.Parse(document["createdAt"].AsString) : null,
updatedBy: document.Contains("updatedBy") && document["updatedBy"] != BsonNull.Value ? document["updatedBy"].AsString : null,
updatedAt: document.Contains("updatedAt") ? DateTimeOffset.Parse(document["updatedAt"].AsString) : null);
}
private static IEnumerable<string>? ExtractStringArray(BsonDocument document, string key)
{
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonArray)
{
return null;
}
return document[key].AsBsonArray.Select(v => v.AsString);
}
private static IEnumerable<KeyValuePair<string, string>>? ExtractStringDictionary(BsonDocument document, string key)
{
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonDocument)
{
return null;
}
var dict = document[key].AsBsonDocument;
return dict.Elements.Select(e => new KeyValuePair<string, string>(e.Name, e.Value.AsString));
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,320 @@
using System.Text.Json;
using System.Collections.Immutable;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// MongoDB implementation of on-call schedule repository.
/// </summary>
internal sealed class NotifyOnCallScheduleRepository : INotifyOnCallScheduleRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
public NotifyOnCallScheduleRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<BsonDocument>(context.Options.OnCallSchedulesCollection);
}
public async Task<NotifyOnCallSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId))
& NotDeletedFilter();
var doc = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc is null ? null : FromBsonDocument(doc);
}
public async Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(
string tenantId,
bool? enabledOnly = null,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
if (enabledOnly == true)
{
filter &= Builders<BsonDocument>.Filter.Eq("enabled", true);
}
var docs = await _collection.Find(filter)
.Sort(Builders<BsonDocument>.Sort.Ascending("name"))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(FromBsonDocument).ToArray();
}
public async Task UpsertAsync(
NotifyOnCallSchedule schedule,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(schedule);
var doc = ToBsonDocument(schedule);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(schedule.TenantId, schedule.ScheduleId));
await _collection.ReplaceOneAsync(filter, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task AddOverrideAsync(
string tenantId,
string scheduleId,
NotifyOnCallOverride override_,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId));
var overrideDoc = OverrideToBson(override_);
var update = Builders<BsonDocument>.Update
.Push("overrides", overrideDoc)
.Set("updatedAt", DateTime.UtcNow);
await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task RemoveOverrideAsync(
string tenantId,
string scheduleId,
string overrideId,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId));
var update = Builders<BsonDocument>.Update
.PullFilter("overrides", Builders<BsonDocument>.Filter.Eq("overrideId", overrideId))
.Set("updatedAt", DateTime.UtcNow);
await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId));
await _collection.UpdateOneAsync(
filter,
Builders<BsonDocument>.Update
.Set("deletedAt", DateTime.UtcNow)
.Set("enabled", false),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static FilterDefinition<BsonDocument> NotDeletedFilter()
=> Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
private static BsonDocument ToBsonDocument(NotifyOnCallSchedule schedule)
{
var json = JsonSerializer.Serialize(schedule, JsonOptions);
var document = BsonDocument.Parse(json);
document["_id"] = CreateDocumentId(schedule.TenantId, schedule.ScheduleId);
// Convert layer rotationInterval to ticks for storage
if (document.Contains("layers") && document["layers"].IsBsonArray)
{
foreach (var layer in document["layers"].AsBsonArray)
{
if (layer.IsBsonDocument && layer.AsBsonDocument.Contains("rotationInterval"))
{
var interval = layer.AsBsonDocument["rotationInterval"].AsString;
if (TimeSpan.TryParse(interval, out var ts))
{
layer.AsBsonDocument["rotationIntervalTicks"] = ts.Ticks;
}
}
}
}
return document;
}
private static BsonDocument OverrideToBson(NotifyOnCallOverride override_)
{
var doc = new BsonDocument
{
["overrideId"] = override_.OverrideId,
["userId"] = override_.UserId,
["startsAt"] = override_.StartsAt.UtcDateTime,
["endsAt"] = override_.EndsAt.UtcDateTime,
["createdAt"] = override_.CreatedAt.UtcDateTime
};
if (override_.Reason is not null)
doc["reason"] = override_.Reason;
if (override_.CreatedBy is not null)
doc["createdBy"] = override_.CreatedBy;
return doc;
}
private static NotifyOnCallSchedule FromBsonDocument(BsonDocument doc)
{
var layers = new List<NotifyOnCallLayer>();
if (doc.Contains("layers") && doc["layers"].IsBsonArray)
{
foreach (var layerVal in doc["layers"].AsBsonArray)
{
if (layerVal.IsBsonDocument)
{
layers.Add(LayerFromBson(layerVal.AsBsonDocument));
}
}
}
var overrides = new List<NotifyOnCallOverride>();
if (doc.Contains("overrides") && doc["overrides"].IsBsonArray)
{
foreach (var overrideVal in doc["overrides"].AsBsonArray)
{
if (overrideVal.IsBsonDocument)
{
overrides.Add(OverrideFromBson(overrideVal.AsBsonDocument));
}
}
}
var metadata = ExtractStringDictionary(doc, "metadata");
return NotifyOnCallSchedule.Create(
scheduleId: doc["scheduleId"].AsString,
tenantId: doc["tenantId"].AsString,
name: doc["name"].AsString,
timeZone: doc["timeZone"].AsString,
layers: layers,
overrides: overrides,
enabled: doc.Contains("enabled") ? doc["enabled"].AsBoolean : true,
description: doc.Contains("description") && doc["description"] != BsonNull.Value ? doc["description"].AsString : null,
metadata: metadata,
createdBy: doc.Contains("createdBy") && doc["createdBy"] != BsonNull.Value ? doc["createdBy"].AsString : null,
createdAt: doc.Contains("createdAt") ? DateTimeOffset.Parse(doc["createdAt"].AsString) : null,
updatedBy: doc.Contains("updatedBy") && doc["updatedBy"] != BsonNull.Value ? doc["updatedBy"].AsString : null,
updatedAt: doc.Contains("updatedAt") ? DateTimeOffset.Parse(doc["updatedAt"].AsString) : null);
}
private static NotifyOnCallLayer LayerFromBson(BsonDocument doc)
{
var rotationInterval = doc.Contains("rotationIntervalTicks")
? TimeSpan.FromTicks(doc["rotationIntervalTicks"].AsInt64)
: TimeSpan.FromDays(7);
var participants = new List<NotifyOnCallParticipant>();
if (doc.Contains("participants") && doc["participants"].IsBsonArray)
{
foreach (var pVal in doc["participants"].AsBsonArray)
{
if (pVal.IsBsonDocument)
{
var pd = pVal.AsBsonDocument;
var contactMethods = new List<NotifyContactMethod>();
if (pd.Contains("contactMethods") && pd["contactMethods"].IsBsonArray)
{
foreach (var cmVal in pd["contactMethods"].AsBsonArray)
{
if (cmVal.IsBsonDocument)
{
var cmd = cmVal.AsBsonDocument;
contactMethods.Add(new NotifyContactMethod(
Enum.Parse<NotifyContactMethodType>(cmd["type"].AsString),
cmd["address"].AsString,
cmd.Contains("priority") ? cmd["priority"].AsInt32 : 0,
cmd.Contains("enabled") ? cmd["enabled"].AsBoolean : true));
}
}
}
participants.Add(NotifyOnCallParticipant.Create(
userId: pd["userId"].AsString,
name: pd.Contains("name") && pd["name"] != BsonNull.Value ? pd["name"].AsString : null,
email: pd.Contains("email") && pd["email"] != BsonNull.Value ? pd["email"].AsString : null,
phone: pd.Contains("phone") && pd["phone"] != BsonNull.Value ? pd["phone"].AsString : null,
contactMethods: contactMethods));
}
}
}
NotifyOnCallRestriction? restrictions = null;
if (doc.Contains("restrictions") && doc["restrictions"] != BsonNull.Value && doc["restrictions"].IsBsonDocument)
{
var rd = doc["restrictions"].AsBsonDocument;
var timeRanges = new List<NotifyTimeRange>();
if (rd.Contains("timeRanges") && rd["timeRanges"].IsBsonArray)
{
foreach (var trVal in rd["timeRanges"].AsBsonArray)
{
if (trVal.IsBsonDocument)
{
var trd = trVal.AsBsonDocument;
timeRanges.Add(new NotifyTimeRange(
dayOfWeek: trd.Contains("dayOfWeek") && trd["dayOfWeek"] != BsonNull.Value
? Enum.Parse<DayOfWeek>(trd["dayOfWeek"].AsString)
: null,
startTime: TimeOnly.Parse(trd["startTime"].AsString),
endTime: TimeOnly.Parse(trd["endTime"].AsString)));
}
}
}
restrictions = NotifyOnCallRestriction.Create(
Enum.Parse<NotifyRestrictionType>(rd["type"].AsString),
timeRanges);
}
return NotifyOnCallLayer.Create(
layerId: doc["layerId"].AsString,
name: doc["name"].AsString,
priority: doc["priority"].AsInt32,
rotationType: Enum.Parse<NotifyRotationType>(doc["rotationType"].AsString),
rotationInterval: rotationInterval,
rotationStartsAt: DateTimeOffset.Parse(doc["rotationStartsAt"].AsString),
participants: participants,
restrictions: restrictions);
}
private static NotifyOnCallOverride OverrideFromBson(BsonDocument doc)
{
return NotifyOnCallOverride.Create(
overrideId: doc["overrideId"].AsString,
userId: doc["userId"].AsString,
startsAt: new DateTimeOffset(doc["startsAt"].ToUniversalTime(), TimeSpan.Zero),
endsAt: new DateTimeOffset(doc["endsAt"].ToUniversalTime(), TimeSpan.Zero),
reason: doc.Contains("reason") && doc["reason"] != BsonNull.Value ? doc["reason"].AsString : null,
createdBy: doc.Contains("createdBy") && doc["createdBy"] != BsonNull.Value ? doc["createdBy"].AsString : null,
createdAt: doc.Contains("createdAt")
? new DateTimeOffset(doc["createdAt"].ToUniversalTime(), TimeSpan.Zero)
: null);
}
private static IEnumerable<KeyValuePair<string, string>>? ExtractStringDictionary(BsonDocument document, string key)
{
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonDocument)
{
return null;
}
var dict = document[key].AsBsonDocument;
return dict.Elements.Select(e => new KeyValuePair<string, string>(e.Name, e.Value.AsString));
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> $"{tenantId}:{resourceId}";
}

View File

@@ -0,0 +1,156 @@
using System.Text.Json;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyOperatorOverrideRepository : INotifyOperatorOverrideRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
public NotifyOperatorOverrideRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<BsonDocument>(context.Options.OperatorOverridesCollection);
}
public async Task UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(@override);
var document = ToBsonDocument(@override);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(@override.TenantId, @override.OverrideId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, overrideId))
& NotDeletedFilter();
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : FromBsonDocument(document);
}
public async Task<IReadOnlyList<NotifyOperatorOverride>> ListActiveAsync(
string tenantId,
DateTimeOffset asOf,
NotifyOverrideType? overrideType = null,
string? channelId = null,
string? ruleId = null,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
& Builders<BsonDocument>.Filter.Gt("expiresAt", asOf.UtcDateTime)
& NotDeletedFilter();
if (overrideType.HasValue)
{
filter &= Builders<BsonDocument>.Filter.Eq("overrideType", overrideType.Value.ToString());
}
if (!string.IsNullOrWhiteSpace(channelId))
{
filter &= Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Eq("channelId", channelId),
Builders<BsonDocument>.Filter.Eq("channelId", BsonNull.Value),
Builders<BsonDocument>.Filter.Exists("channelId", false));
}
if (!string.IsNullOrWhiteSpace(ruleId))
{
filter &= Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Eq("ruleId", ruleId),
Builders<BsonDocument>.Filter.Eq("ruleId", BsonNull.Value),
Builders<BsonDocument>.Filter.Exists("ruleId", false));
}
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return cursor.Select(FromBsonDocument).ToArray();
}
public async Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(
string tenantId,
bool? activeOnly = null,
DateTimeOffset? asOf = null,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
if (activeOnly == true && asOf.HasValue)
{
filter &= Builders<BsonDocument>.Filter.Gt("expiresAt", asOf.Value.UtcDateTime);
}
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return cursor.Select(FromBsonDocument).ToArray();
}
public async Task DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, overrideId));
await _collection.UpdateOneAsync(filter,
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
public async Task DeleteExpiredAsync(string tenantId, DateTimeOffset olderThan, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
& Builders<BsonDocument>.Filter.Lt("expiresAt", olderThan.UtcDateTime)
& NotDeletedFilter();
await _collection.UpdateManyAsync(filter,
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow),
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static FilterDefinition<BsonDocument> NotDeletedFilter()
=> Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
private static BsonDocument ToBsonDocument(NotifyOperatorOverride @override)
{
var json = JsonSerializer.Serialize(@override, JsonOptions);
var document = BsonDocument.Parse(json);
document["_id"] = BsonValue.Create(CreateDocumentId(@override.TenantId, @override.OverrideId));
// Convert DateTimeOffset to DateTime for Mongo indexing
document["expiresAt"] = @override.ExpiresAt.UtcDateTime;
return document;
}
private static NotifyOperatorOverride FromBsonDocument(BsonDocument document)
{
var expiresAt = document["expiresAt"].ToUniversalTime();
var overrideTypeStr = document["overrideType"].AsString;
var overrideType = Enum.Parse<NotifyOverrideType>(overrideTypeStr, ignoreCase: true);
return NotifyOperatorOverride.Create(
overrideId: document["overrideId"].AsString,
tenantId: document["tenantId"].AsString,
overrideType: overrideType,
expiresAt: new DateTimeOffset(expiresAt, TimeSpan.Zero),
channelId: document.Contains("channelId") && document["channelId"] != BsonNull.Value ? document["channelId"].AsString : null,
ruleId: document.Contains("ruleId") && document["ruleId"] != BsonNull.Value ? document["ruleId"].AsString : null,
reason: document.Contains("reason") && document["reason"] != BsonNull.Value ? document["reason"].AsString : null,
createdBy: document.Contains("createdBy") && document["createdBy"] != BsonNull.Value ? document["createdBy"].AsString : null,
createdAt: document.Contains("createdAt") ? DateTimeOffset.Parse(document["createdAt"].AsString) : null);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,142 @@
using System.Text.Json;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyQuietHoursRepository : INotifyQuietHoursRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
public NotifyQuietHoursRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<BsonDocument>(context.Options.QuietHoursCollection);
}
public async Task UpsertAsync(NotifyQuietHoursSchedule schedule, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(schedule);
var document = ToBsonDocument(schedule);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(schedule.TenantId, schedule.ScheduleId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyQuietHoursSchedule?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId))
& NotDeletedFilter();
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : FromBsonDocument(document);
}
public async Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
string tenantId,
string? channelId = null,
bool? enabledOnly = null,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
if (!string.IsNullOrWhiteSpace(channelId))
{
filter &= Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Eq("channelId", channelId),
Builders<BsonDocument>.Filter.Eq("channelId", BsonNull.Value),
Builders<BsonDocument>.Filter.Exists("channelId", false));
}
if (enabledOnly == true)
{
filter &= Builders<BsonDocument>.Filter.Eq("enabled", true);
}
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return cursor.Select(FromBsonDocument).ToArray();
}
public async Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListEnabledAsync(
string tenantId,
string? channelId = null,
CancellationToken cancellationToken = default)
{
return await ListAsync(tenantId, channelId, enabledOnly: true, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId));
await _collection.UpdateOneAsync(filter,
Builders<BsonDocument>.Update
.Set("deletedAt", DateTime.UtcNow)
.Set("enabled", false),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static FilterDefinition<BsonDocument> NotDeletedFilter()
=> Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
private static BsonDocument ToBsonDocument(NotifyQuietHoursSchedule schedule)
{
var json = JsonSerializer.Serialize(schedule, JsonOptions);
var document = BsonDocument.Parse(json);
document["_id"] = BsonValue.Create(CreateDocumentId(schedule.TenantId, schedule.ScheduleId));
// Convert Duration to Ticks for Mongo storage
document["durationTicks"] = schedule.Duration.Ticks;
return document;
}
private static NotifyQuietHoursSchedule FromBsonDocument(BsonDocument document)
{
// Handle duration from ticks
var durationTicks = document.Contains("durationTicks") ? document["durationTicks"].AsInt64 : TimeSpan.FromHours(8).Ticks;
var duration = TimeSpan.FromTicks(durationTicks);
return NotifyQuietHoursSchedule.Create(
scheduleId: document["scheduleId"].AsString,
tenantId: document["tenantId"].AsString,
name: document["name"].AsString,
cronExpression: document["cronExpression"].AsString,
duration: duration,
timeZone: document["timeZone"].AsString,
channelId: document.Contains("channelId") && document["channelId"] != BsonNull.Value ? document["channelId"].AsString : null,
enabled: document.Contains("enabled") ? document["enabled"].AsBoolean : true,
description: document.Contains("description") && document["description"] != BsonNull.Value ? document["description"].AsString : null,
metadata: ExtractStringDictionary(document, "metadata"),
createdBy: document.Contains("createdBy") && document["createdBy"] != BsonNull.Value ? document["createdBy"].AsString : null,
createdAt: document.Contains("createdAt") ? DateTimeOffset.Parse(document["createdAt"].AsString) : null,
updatedBy: document.Contains("updatedBy") && document["updatedBy"] != BsonNull.Value ? document["updatedBy"].AsString : null,
updatedAt: document.Contains("updatedAt") ? DateTimeOffset.Parse(document["updatedAt"].AsString) : null);
}
private static IEnumerable<KeyValuePair<string, string>>? ExtractStringDictionary(BsonDocument document, string key)
{
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonDocument)
{
return null;
}
var dict = document[key].AsBsonDocument;
return dict.Elements.Select(e => new KeyValuePair<string, string>(e.Name, e.Value.AsString));
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,145 @@
using System.Text.Json;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyThrottleConfigRepository : INotifyThrottleConfigRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
public NotifyThrottleConfigRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<BsonDocument>(context.Options.ThrottleConfigsCollection);
}
public async Task UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(config);
var document = ToBsonDocument(config);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(config.TenantId, config.ConfigId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, configId))
& NotDeletedFilter();
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : FromBsonDocument(document);
}
public async Task<NotifyThrottleConfig?> GetDefaultAsync(string tenantId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
& Builders<BsonDocument>.Filter.Eq("isDefault", true)
& NotDeletedFilter();
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : FromBsonDocument(document);
}
public async Task<NotifyThrottleConfig?> GetForChannelAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
// First try to find a channel-specific config
var channelFilter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
& Builders<BsonDocument>.Filter.Eq("channelId", channelId)
& Builders<BsonDocument>.Filter.Eq("enabled", true)
& NotDeletedFilter();
var document = await _collection.Find(channelFilter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (document is not null)
{
return FromBsonDocument(document);
}
// Fall back to default config
return await GetDefaultAsync(tenantId, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return cursor.Select(FromBsonDocument).ToArray();
}
public async Task DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, configId));
await _collection.UpdateOneAsync(filter,
Builders<BsonDocument>.Update
.Set("deletedAt", DateTime.UtcNow)
.Set("enabled", false),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static FilterDefinition<BsonDocument> NotDeletedFilter()
=> Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
private static BsonDocument ToBsonDocument(NotifyThrottleConfig config)
{
var json = JsonSerializer.Serialize(config, JsonOptions);
var document = BsonDocument.Parse(json);
document["_id"] = BsonValue.Create(CreateDocumentId(config.TenantId, config.ConfigId));
// Convert TimeSpan to ticks for Mongo storage
document["defaultWindowTicks"] = config.DefaultWindow.Ticks;
return document;
}
private static NotifyThrottleConfig FromBsonDocument(BsonDocument document)
{
var defaultWindowTicks = document.Contains("defaultWindowTicks") ? document["defaultWindowTicks"].AsInt64 : TimeSpan.FromMinutes(5).Ticks;
var defaultWindow = TimeSpan.FromTicks(defaultWindowTicks);
return NotifyThrottleConfig.Create(
configId: document["configId"].AsString,
tenantId: document["tenantId"].AsString,
name: document["name"].AsString,
defaultWindow: defaultWindow,
maxNotificationsPerWindow: document.Contains("maxNotificationsPerWindow") && document["maxNotificationsPerWindow"] != BsonNull.Value
? document["maxNotificationsPerWindow"].AsInt32 : null,
channelId: document.Contains("channelId") && document["channelId"] != BsonNull.Value ? document["channelId"].AsString : null,
isDefault: document.Contains("isDefault") ? document["isDefault"].AsBoolean : false,
enabled: document.Contains("enabled") ? document["enabled"].AsBoolean : true,
description: document.Contains("description") && document["description"] != BsonNull.Value ? document["description"].AsString : null,
metadata: ExtractStringDictionary(document, "metadata"),
createdBy: document.Contains("createdBy") && document["createdBy"] != BsonNull.Value ? document["createdBy"].AsString : null,
createdAt: document.Contains("createdAt") ? DateTimeOffset.Parse(document["createdAt"].AsString) : null,
updatedBy: document.Contains("updatedBy") && document["updatedBy"] != BsonNull.Value ? document["updatedBy"].AsString : null,
updatedAt: document.Contains("updatedAt") ? DateTimeOffset.Parse(document["updatedAt"].AsString) : null);
}
private static IEnumerable<KeyValuePair<string, string>>? ExtractStringDictionary(BsonDocument document, string key)
{
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonDocument)
{
return null;
}
var dict = document[key].AsBsonDocument;
return dict.Elements.Select(e => new KeyValuePair<string, string>(e.Name, e.Value.AsString));
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -27,6 +27,15 @@ public static class ServiceCollectionExtensions
services.AddSingleton<INotifyDigestRepository, NotifyDigestRepository>();
services.AddSingleton<INotifyLockRepository, NotifyLockRepository>();
services.AddSingleton<INotifyAuditRepository, NotifyAuditRepository>();
services.AddSingleton<INotifyQuietHoursRepository, NotifyQuietHoursRepository>();
services.AddSingleton<INotifyMaintenanceWindowRepository, NotifyMaintenanceWindowRepository>();
services.AddSingleton<INotifyThrottleConfigRepository, NotifyThrottleConfigRepository>();
services.AddSingleton<INotifyOperatorOverrideRepository, NotifyOperatorOverrideRepository>();
services.AddSingleton<INotifyEscalationPolicyRepository, NotifyEscalationPolicyRepository>();
services.AddSingleton<INotifyEscalationStateRepository, NotifyEscalationStateRepository>();
services.AddSingleton<INotifyOnCallScheduleRepository, NotifyOnCallScheduleRepository>();
services.AddSingleton<INotifyInboxRepository, NotifyInboxRepository>();
services.AddSingleton<INotifyLocalizationRepository, NotifyLocalizationRepository>();
return services;
}