Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
# StellaOps.Notify.Models — Agent Charter
|
||||
|
||||
## Mission
|
||||
Define Notify DTOs and contracts per `docs/modules/notify/ARCHITECTURE.md`.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/notify/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
# StellaOps.Notify.Models — Agent Charter
|
||||
|
||||
## Mission
|
||||
Define Notify DTOs and contracts per `docs/modules/notify/ARCHITECTURE.md`.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/notify/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
|
||||
@@ -17,6 +17,7 @@ public enum NotifyChannelType
|
||||
OpsGenie,
|
||||
Cli,
|
||||
InAppInbox,
|
||||
InApp,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,478 +1,478 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Escalation policy defining how incidents are escalated through multiple levels.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationPolicy
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationPolicy(
|
||||
string policyId,
|
||||
string tenantId,
|
||||
string name,
|
||||
ImmutableArray<NotifyEscalationLevel> levels,
|
||||
bool enabled = true,
|
||||
bool repeatEnabled = false,
|
||||
int? repeatCount = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
PolicyId = NotifyValidation.EnsureNotNullOrWhiteSpace(policyId, nameof(policyId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Levels = NormalizeLevels(levels);
|
||||
|
||||
if (Levels.IsDefaultOrEmpty)
|
||||
{
|
||||
throw new ArgumentException("At least one escalation level is required.", nameof(levels));
|
||||
}
|
||||
|
||||
Enabled = enabled;
|
||||
RepeatEnabled = repeatEnabled;
|
||||
RepeatCount = repeatCount is > 0 ? repeatCount : null;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyEscalationPolicy Create(
|
||||
string policyId,
|
||||
string tenantId,
|
||||
string name,
|
||||
IEnumerable<NotifyEscalationLevel>? levels,
|
||||
bool enabled = true,
|
||||
bool repeatEnabled = false,
|
||||
int? repeatCount = null,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyEscalationPolicy(
|
||||
policyId,
|
||||
tenantId,
|
||||
name,
|
||||
ToImmutableArray(levels),
|
||||
enabled,
|
||||
repeatEnabled,
|
||||
repeatCount,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string PolicyId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of escalation levels.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationLevel> Levels { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to repeat the escalation cycle after reaching the last level.
|
||||
/// </summary>
|
||||
public bool RepeatEnabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of times to repeat the escalation cycle.
|
||||
/// </summary>
|
||||
public int? RepeatCount { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableArray<NotifyEscalationLevel> NormalizeLevels(ImmutableArray<NotifyEscalationLevel> levels)
|
||||
{
|
||||
if (levels.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<NotifyEscalationLevel>.Empty;
|
||||
}
|
||||
|
||||
return levels
|
||||
.Where(static l => l is not null)
|
||||
.OrderBy(static l => l.Order)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyEscalationLevel> ToImmutableArray(IEnumerable<NotifyEscalationLevel>? levels)
|
||||
=> levels is null ? ImmutableArray<NotifyEscalationLevel>.Empty : levels.ToImmutableArray();
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single level in an escalation policy.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationLevel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationLevel(
|
||||
int order,
|
||||
TimeSpan escalateAfter,
|
||||
ImmutableArray<NotifyEscalationTarget> targets,
|
||||
string? name = null,
|
||||
bool notifyAll = true)
|
||||
{
|
||||
Order = order >= 0 ? order : 0;
|
||||
EscalateAfter = escalateAfter > TimeSpan.Zero ? escalateAfter : TimeSpan.FromMinutes(15);
|
||||
Targets = NormalizeTargets(targets);
|
||||
Name = NotifyValidation.TrimToNull(name);
|
||||
NotifyAll = notifyAll;
|
||||
}
|
||||
|
||||
public static NotifyEscalationLevel Create(
|
||||
int order,
|
||||
TimeSpan escalateAfter,
|
||||
IEnumerable<NotifyEscalationTarget>? targets,
|
||||
string? name = null,
|
||||
bool notifyAll = true)
|
||||
{
|
||||
return new NotifyEscalationLevel(
|
||||
order,
|
||||
escalateAfter,
|
||||
ToImmutableArray(targets),
|
||||
name,
|
||||
notifyAll);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order of this level in the escalation chain (0-based).
|
||||
/// </summary>
|
||||
public int Order { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Time to wait before escalating to this level.
|
||||
/// </summary>
|
||||
public TimeSpan EscalateAfter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Targets to notify at this level.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationTarget> Targets { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional name for this level (e.g., "Primary", "Secondary", "Management").
|
||||
/// </summary>
|
||||
public string? Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to notify all targets at this level, or just the first available.
|
||||
/// </summary>
|
||||
public bool NotifyAll { get; }
|
||||
|
||||
private static ImmutableArray<NotifyEscalationTarget> NormalizeTargets(ImmutableArray<NotifyEscalationTarget> targets)
|
||||
{
|
||||
if (targets.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<NotifyEscalationTarget>.Empty;
|
||||
}
|
||||
|
||||
return targets
|
||||
.Where(static t => t is not null)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyEscalationTarget> ToImmutableArray(IEnumerable<NotifyEscalationTarget>? targets)
|
||||
=> targets is null ? ImmutableArray<NotifyEscalationTarget>.Empty : targets.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Target to notify during escalation.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationTarget
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationTarget(
|
||||
NotifyEscalationTargetType type,
|
||||
string targetId,
|
||||
string? channelOverride = null)
|
||||
{
|
||||
Type = type;
|
||||
TargetId = NotifyValidation.EnsureNotNullOrWhiteSpace(targetId, nameof(targetId));
|
||||
ChannelOverride = NotifyValidation.TrimToNull(channelOverride);
|
||||
}
|
||||
|
||||
public static NotifyEscalationTarget Create(
|
||||
NotifyEscalationTargetType type,
|
||||
string targetId,
|
||||
string? channelOverride = null)
|
||||
{
|
||||
return new NotifyEscalationTarget(type, targetId, channelOverride);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of target (user, on-call schedule, channel, external service).
|
||||
/// </summary>
|
||||
public NotifyEscalationTargetType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the target (user ID, schedule ID, channel ID, or external service ID).
|
||||
/// </summary>
|
||||
public string TargetId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel override for this target.
|
||||
/// </summary>
|
||||
public string? ChannelOverride { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of escalation target.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyEscalationTargetType
|
||||
{
|
||||
/// <summary>
|
||||
/// A specific user.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// An on-call schedule (resolves to current on-call user).
|
||||
/// </summary>
|
||||
OnCallSchedule,
|
||||
|
||||
/// <summary>
|
||||
/// A notification channel directly.
|
||||
/// </summary>
|
||||
Channel,
|
||||
|
||||
/// <summary>
|
||||
/// External service (PagerDuty, OpsGenie, etc.).
|
||||
/// </summary>
|
||||
ExternalService,
|
||||
|
||||
/// <summary>
|
||||
/// In-app inbox notification.
|
||||
/// </summary>
|
||||
InAppInbox
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the current state of an escalation for an incident.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationState
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationState(
|
||||
string stateId,
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
int currentLevel,
|
||||
int repeatIteration,
|
||||
NotifyEscalationStatus status,
|
||||
ImmutableArray<NotifyEscalationAttempt> attempts,
|
||||
DateTimeOffset? nextEscalationAt = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
DateTimeOffset? acknowledgedAt = null,
|
||||
string? acknowledgedBy = null,
|
||||
DateTimeOffset? resolvedAt = null,
|
||||
string? resolvedBy = null)
|
||||
{
|
||||
StateId = NotifyValidation.EnsureNotNullOrWhiteSpace(stateId, nameof(stateId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
IncidentId = NotifyValidation.EnsureNotNullOrWhiteSpace(incidentId, nameof(incidentId));
|
||||
PolicyId = NotifyValidation.EnsureNotNullOrWhiteSpace(policyId, nameof(policyId));
|
||||
CurrentLevel = currentLevel >= 0 ? currentLevel : 0;
|
||||
RepeatIteration = repeatIteration >= 0 ? repeatIteration : 0;
|
||||
Status = status;
|
||||
Attempts = attempts.IsDefault ? ImmutableArray<NotifyEscalationAttempt>.Empty : attempts;
|
||||
NextEscalationAt = NotifyValidation.EnsureUtc(nextEscalationAt);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
AcknowledgedAt = NotifyValidation.EnsureUtc(acknowledgedAt);
|
||||
AcknowledgedBy = NotifyValidation.TrimToNull(acknowledgedBy);
|
||||
ResolvedAt = NotifyValidation.EnsureUtc(resolvedAt);
|
||||
ResolvedBy = NotifyValidation.TrimToNull(resolvedBy);
|
||||
}
|
||||
|
||||
public static NotifyEscalationState Create(
|
||||
string stateId,
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
int currentLevel = 0,
|
||||
int repeatIteration = 0,
|
||||
NotifyEscalationStatus status = NotifyEscalationStatus.Active,
|
||||
IEnumerable<NotifyEscalationAttempt>? attempts = null,
|
||||
DateTimeOffset? nextEscalationAt = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
DateTimeOffset? acknowledgedAt = null,
|
||||
string? acknowledgedBy = null,
|
||||
DateTimeOffset? resolvedAt = null,
|
||||
string? resolvedBy = null)
|
||||
{
|
||||
return new NotifyEscalationState(
|
||||
stateId,
|
||||
tenantId,
|
||||
incidentId,
|
||||
policyId,
|
||||
currentLevel,
|
||||
repeatIteration,
|
||||
status,
|
||||
attempts?.ToImmutableArray() ?? ImmutableArray<NotifyEscalationAttempt>.Empty,
|
||||
nextEscalationAt,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
acknowledgedAt,
|
||||
acknowledgedBy,
|
||||
resolvedAt,
|
||||
resolvedBy);
|
||||
}
|
||||
|
||||
public string StateId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string IncidentId { get; }
|
||||
|
||||
public string PolicyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current escalation level (0-based index).
|
||||
/// </summary>
|
||||
public int CurrentLevel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current repeat iteration (0 = first pass through levels).
|
||||
/// </summary>
|
||||
public int RepeatIteration { get; }
|
||||
|
||||
public NotifyEscalationStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// History of escalation attempts.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationAttempt> Attempts { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the next escalation will occur.
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextEscalationAt { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
public DateTimeOffset? AcknowledgedAt { get; }
|
||||
|
||||
public string? AcknowledgedBy { get; }
|
||||
|
||||
public DateTimeOffset? ResolvedAt { get; }
|
||||
|
||||
public string? ResolvedBy { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record of a single escalation attempt.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationAttempt
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationAttempt(
|
||||
int level,
|
||||
int iteration,
|
||||
DateTimeOffset timestamp,
|
||||
ImmutableArray<string> notifiedTargets,
|
||||
bool success,
|
||||
string? failureReason = null)
|
||||
{
|
||||
Level = level >= 0 ? level : 0;
|
||||
Iteration = iteration >= 0 ? iteration : 0;
|
||||
Timestamp = NotifyValidation.EnsureUtc(timestamp);
|
||||
NotifiedTargets = notifiedTargets.IsDefault ? ImmutableArray<string>.Empty : notifiedTargets;
|
||||
Success = success;
|
||||
FailureReason = NotifyValidation.TrimToNull(failureReason);
|
||||
}
|
||||
|
||||
public int Level { get; }
|
||||
|
||||
public int Iteration { get; }
|
||||
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public ImmutableArray<string> NotifiedTargets { get; }
|
||||
|
||||
public bool Success { get; }
|
||||
|
||||
public string? FailureReason { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of an escalation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyEscalationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Escalation is active and being processed.
|
||||
/// </summary>
|
||||
Active,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was acknowledged.
|
||||
/// </summary>
|
||||
Acknowledged,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was resolved.
|
||||
/// </summary>
|
||||
Resolved,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation exhausted all levels and repeats.
|
||||
/// </summary>
|
||||
Exhausted,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was manually suppressed.
|
||||
/// </summary>
|
||||
Suppressed
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Escalation policy defining how incidents are escalated through multiple levels.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationPolicy
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationPolicy(
|
||||
string policyId,
|
||||
string tenantId,
|
||||
string name,
|
||||
ImmutableArray<NotifyEscalationLevel> levels,
|
||||
bool enabled = true,
|
||||
bool repeatEnabled = false,
|
||||
int? repeatCount = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
PolicyId = NotifyValidation.EnsureNotNullOrWhiteSpace(policyId, nameof(policyId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Levels = NormalizeLevels(levels);
|
||||
|
||||
if (Levels.IsDefaultOrEmpty)
|
||||
{
|
||||
throw new ArgumentException("At least one escalation level is required.", nameof(levels));
|
||||
}
|
||||
|
||||
Enabled = enabled;
|
||||
RepeatEnabled = repeatEnabled;
|
||||
RepeatCount = repeatCount is > 0 ? repeatCount : null;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyEscalationPolicy Create(
|
||||
string policyId,
|
||||
string tenantId,
|
||||
string name,
|
||||
IEnumerable<NotifyEscalationLevel>? levels,
|
||||
bool enabled = true,
|
||||
bool repeatEnabled = false,
|
||||
int? repeatCount = null,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyEscalationPolicy(
|
||||
policyId,
|
||||
tenantId,
|
||||
name,
|
||||
ToImmutableArray(levels),
|
||||
enabled,
|
||||
repeatEnabled,
|
||||
repeatCount,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string PolicyId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of escalation levels.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationLevel> Levels { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to repeat the escalation cycle after reaching the last level.
|
||||
/// </summary>
|
||||
public bool RepeatEnabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of times to repeat the escalation cycle.
|
||||
/// </summary>
|
||||
public int? RepeatCount { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableArray<NotifyEscalationLevel> NormalizeLevels(ImmutableArray<NotifyEscalationLevel> levels)
|
||||
{
|
||||
if (levels.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<NotifyEscalationLevel>.Empty;
|
||||
}
|
||||
|
||||
return levels
|
||||
.Where(static l => l is not null)
|
||||
.OrderBy(static l => l.Order)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyEscalationLevel> ToImmutableArray(IEnumerable<NotifyEscalationLevel>? levels)
|
||||
=> levels is null ? ImmutableArray<NotifyEscalationLevel>.Empty : levels.ToImmutableArray();
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single level in an escalation policy.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationLevel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationLevel(
|
||||
int order,
|
||||
TimeSpan escalateAfter,
|
||||
ImmutableArray<NotifyEscalationTarget> targets,
|
||||
string? name = null,
|
||||
bool notifyAll = true)
|
||||
{
|
||||
Order = order >= 0 ? order : 0;
|
||||
EscalateAfter = escalateAfter > TimeSpan.Zero ? escalateAfter : TimeSpan.FromMinutes(15);
|
||||
Targets = NormalizeTargets(targets);
|
||||
Name = NotifyValidation.TrimToNull(name);
|
||||
NotifyAll = notifyAll;
|
||||
}
|
||||
|
||||
public static NotifyEscalationLevel Create(
|
||||
int order,
|
||||
TimeSpan escalateAfter,
|
||||
IEnumerable<NotifyEscalationTarget>? targets,
|
||||
string? name = null,
|
||||
bool notifyAll = true)
|
||||
{
|
||||
return new NotifyEscalationLevel(
|
||||
order,
|
||||
escalateAfter,
|
||||
ToImmutableArray(targets),
|
||||
name,
|
||||
notifyAll);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order of this level in the escalation chain (0-based).
|
||||
/// </summary>
|
||||
public int Order { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Time to wait before escalating to this level.
|
||||
/// </summary>
|
||||
public TimeSpan EscalateAfter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Targets to notify at this level.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationTarget> Targets { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional name for this level (e.g., "Primary", "Secondary", "Management").
|
||||
/// </summary>
|
||||
public string? Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to notify all targets at this level, or just the first available.
|
||||
/// </summary>
|
||||
public bool NotifyAll { get; }
|
||||
|
||||
private static ImmutableArray<NotifyEscalationTarget> NormalizeTargets(ImmutableArray<NotifyEscalationTarget> targets)
|
||||
{
|
||||
if (targets.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<NotifyEscalationTarget>.Empty;
|
||||
}
|
||||
|
||||
return targets
|
||||
.Where(static t => t is not null)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyEscalationTarget> ToImmutableArray(IEnumerable<NotifyEscalationTarget>? targets)
|
||||
=> targets is null ? ImmutableArray<NotifyEscalationTarget>.Empty : targets.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Target to notify during escalation.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationTarget
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationTarget(
|
||||
NotifyEscalationTargetType type,
|
||||
string targetId,
|
||||
string? channelOverride = null)
|
||||
{
|
||||
Type = type;
|
||||
TargetId = NotifyValidation.EnsureNotNullOrWhiteSpace(targetId, nameof(targetId));
|
||||
ChannelOverride = NotifyValidation.TrimToNull(channelOverride);
|
||||
}
|
||||
|
||||
public static NotifyEscalationTarget Create(
|
||||
NotifyEscalationTargetType type,
|
||||
string targetId,
|
||||
string? channelOverride = null)
|
||||
{
|
||||
return new NotifyEscalationTarget(type, targetId, channelOverride);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of target (user, on-call schedule, channel, external service).
|
||||
/// </summary>
|
||||
public NotifyEscalationTargetType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the target (user ID, schedule ID, channel ID, or external service ID).
|
||||
/// </summary>
|
||||
public string TargetId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel override for this target.
|
||||
/// </summary>
|
||||
public string? ChannelOverride { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of escalation target.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyEscalationTargetType
|
||||
{
|
||||
/// <summary>
|
||||
/// A specific user.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// An on-call schedule (resolves to current on-call user).
|
||||
/// </summary>
|
||||
OnCallSchedule,
|
||||
|
||||
/// <summary>
|
||||
/// A notification channel directly.
|
||||
/// </summary>
|
||||
Channel,
|
||||
|
||||
/// <summary>
|
||||
/// External service (PagerDuty, OpsGenie, etc.).
|
||||
/// </summary>
|
||||
ExternalService,
|
||||
|
||||
/// <summary>
|
||||
/// In-app inbox notification.
|
||||
/// </summary>
|
||||
InAppInbox
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the current state of an escalation for an incident.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationState
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationState(
|
||||
string stateId,
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
int currentLevel,
|
||||
int repeatIteration,
|
||||
NotifyEscalationStatus status,
|
||||
ImmutableArray<NotifyEscalationAttempt> attempts,
|
||||
DateTimeOffset? nextEscalationAt = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
DateTimeOffset? acknowledgedAt = null,
|
||||
string? acknowledgedBy = null,
|
||||
DateTimeOffset? resolvedAt = null,
|
||||
string? resolvedBy = null)
|
||||
{
|
||||
StateId = NotifyValidation.EnsureNotNullOrWhiteSpace(stateId, nameof(stateId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
IncidentId = NotifyValidation.EnsureNotNullOrWhiteSpace(incidentId, nameof(incidentId));
|
||||
PolicyId = NotifyValidation.EnsureNotNullOrWhiteSpace(policyId, nameof(policyId));
|
||||
CurrentLevel = currentLevel >= 0 ? currentLevel : 0;
|
||||
RepeatIteration = repeatIteration >= 0 ? repeatIteration : 0;
|
||||
Status = status;
|
||||
Attempts = attempts.IsDefault ? ImmutableArray<NotifyEscalationAttempt>.Empty : attempts;
|
||||
NextEscalationAt = NotifyValidation.EnsureUtc(nextEscalationAt);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
AcknowledgedAt = NotifyValidation.EnsureUtc(acknowledgedAt);
|
||||
AcknowledgedBy = NotifyValidation.TrimToNull(acknowledgedBy);
|
||||
ResolvedAt = NotifyValidation.EnsureUtc(resolvedAt);
|
||||
ResolvedBy = NotifyValidation.TrimToNull(resolvedBy);
|
||||
}
|
||||
|
||||
public static NotifyEscalationState Create(
|
||||
string stateId,
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
int currentLevel = 0,
|
||||
int repeatIteration = 0,
|
||||
NotifyEscalationStatus status = NotifyEscalationStatus.Active,
|
||||
IEnumerable<NotifyEscalationAttempt>? attempts = null,
|
||||
DateTimeOffset? nextEscalationAt = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
DateTimeOffset? acknowledgedAt = null,
|
||||
string? acknowledgedBy = null,
|
||||
DateTimeOffset? resolvedAt = null,
|
||||
string? resolvedBy = null)
|
||||
{
|
||||
return new NotifyEscalationState(
|
||||
stateId,
|
||||
tenantId,
|
||||
incidentId,
|
||||
policyId,
|
||||
currentLevel,
|
||||
repeatIteration,
|
||||
status,
|
||||
attempts?.ToImmutableArray() ?? ImmutableArray<NotifyEscalationAttempt>.Empty,
|
||||
nextEscalationAt,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
acknowledgedAt,
|
||||
acknowledgedBy,
|
||||
resolvedAt,
|
||||
resolvedBy);
|
||||
}
|
||||
|
||||
public string StateId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string IncidentId { get; }
|
||||
|
||||
public string PolicyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current escalation level (0-based index).
|
||||
/// </summary>
|
||||
public int CurrentLevel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current repeat iteration (0 = first pass through levels).
|
||||
/// </summary>
|
||||
public int RepeatIteration { get; }
|
||||
|
||||
public NotifyEscalationStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// History of escalation attempts.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationAttempt> Attempts { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the next escalation will occur.
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextEscalationAt { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
public DateTimeOffset? AcknowledgedAt { get; }
|
||||
|
||||
public string? AcknowledgedBy { get; }
|
||||
|
||||
public DateTimeOffset? ResolvedAt { get; }
|
||||
|
||||
public string? ResolvedBy { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record of a single escalation attempt.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationAttempt
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationAttempt(
|
||||
int level,
|
||||
int iteration,
|
||||
DateTimeOffset timestamp,
|
||||
ImmutableArray<string> notifiedTargets,
|
||||
bool success,
|
||||
string? failureReason = null)
|
||||
{
|
||||
Level = level >= 0 ? level : 0;
|
||||
Iteration = iteration >= 0 ? iteration : 0;
|
||||
Timestamp = NotifyValidation.EnsureUtc(timestamp);
|
||||
NotifiedTargets = notifiedTargets.IsDefault ? ImmutableArray<string>.Empty : notifiedTargets;
|
||||
Success = success;
|
||||
FailureReason = NotifyValidation.TrimToNull(failureReason);
|
||||
}
|
||||
|
||||
public int Level { get; }
|
||||
|
||||
public int Iteration { get; }
|
||||
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public ImmutableArray<string> NotifiedTargets { get; }
|
||||
|
||||
public bool Success { get; }
|
||||
|
||||
public string? FailureReason { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of an escalation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyEscalationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Escalation is active and being processed.
|
||||
/// </summary>
|
||||
Active,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was acknowledged.
|
||||
/// </summary>
|
||||
Acknowledged,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was resolved.
|
||||
/// </summary>
|
||||
Resolved,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation exhausted all levels and repeats.
|
||||
/// </summary>
|
||||
Exhausted,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was manually suppressed.
|
||||
/// </summary>
|
||||
Suppressed
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Known platform event kind identifiers consumed by Notify.
|
||||
/// </summary>
|
||||
public static class NotifyEventKinds
|
||||
{
|
||||
public const string ScannerReportReady = "scanner.report.ready";
|
||||
public const string ScannerScanCompleted = "scanner.scan.completed";
|
||||
public const string SchedulerRescanDelta = "scheduler.rescan.delta";
|
||||
public const string AttestorLogged = "attestor.logged";
|
||||
public const string ZastavaAdmission = "zastava.admission";
|
||||
public const string ConselierExportCompleted = "conselier.export.completed";
|
||||
public const string ExcitorExportCompleted = "excitor.export.completed";
|
||||
public const string AirgapTimeDrift = "airgap.time.drift";
|
||||
public const string AirgapBundleImport = "airgap.bundle.import";
|
||||
public const string AirgapPortableExportCompleted = "airgap.portable.export.completed";
|
||||
}
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Known platform event kind identifiers consumed by Notify.
|
||||
/// </summary>
|
||||
public static class NotifyEventKinds
|
||||
{
|
||||
public const string ScannerReportReady = "scanner.report.ready";
|
||||
public const string ScannerScanCompleted = "scanner.scan.completed";
|
||||
public const string SchedulerRescanDelta = "scheduler.rescan.delta";
|
||||
public const string AttestorLogged = "attestor.logged";
|
||||
public const string ZastavaAdmission = "zastava.admission";
|
||||
public const string ConselierExportCompleted = "conselier.export.completed";
|
||||
public const string ExcitorExportCompleted = "excitor.export.completed";
|
||||
public const string AirgapTimeDrift = "airgap.time.drift";
|
||||
public const string AirgapBundleImport = "airgap.bundle.import";
|
||||
public const string AirgapPortableExportCompleted = "airgap.portable.export.completed";
|
||||
}
|
||||
|
||||
@@ -1,233 +1,233 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A localization bundle containing translated strings for a specific locale.
|
||||
/// </summary>
|
||||
public sealed record NotifyLocalizationBundle
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyLocalizationBundle(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string locale,
|
||||
string bundleKey,
|
||||
ImmutableDictionary<string, string> strings,
|
||||
bool isDefault = false,
|
||||
string? parentLocale = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
BundleId = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleId, nameof(bundleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Locale = NotifyValidation.EnsureNotNullOrWhiteSpace(locale, nameof(locale)).ToLowerInvariant();
|
||||
BundleKey = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleKey, nameof(bundleKey));
|
||||
Strings = strings;
|
||||
IsDefault = isDefault;
|
||||
ParentLocale = NormalizeParentLocale(parentLocale, Locale);
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyLocalizationBundle Create(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string locale,
|
||||
string bundleKey,
|
||||
IEnumerable<KeyValuePair<string, string>>? strings = null,
|
||||
bool isDefault = false,
|
||||
string? parentLocale = null,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyLocalizationBundle(
|
||||
bundleId,
|
||||
tenantId,
|
||||
locale,
|
||||
bundleKey,
|
||||
ToImmutableDictionary(strings) ?? ImmutableDictionary<string, string>.Empty,
|
||||
isDefault,
|
||||
parentLocale,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this bundle.
|
||||
/// </summary>
|
||||
public string BundleId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID this bundle belongs to.
|
||||
/// </summary>
|
||||
public string TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Locale code (e.g., "en-us", "fr-fr", "ja-jp").
|
||||
/// </summary>
|
||||
public string Locale { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle key for grouping related bundles (e.g., "notifications", "email-subjects").
|
||||
/// </summary>
|
||||
public string BundleKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary of string key to translated value.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Strings { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the default/fallback bundle for the bundle key.
|
||||
/// </summary>
|
||||
public bool IsDefault { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent locale for fallback chain (e.g., "en" for "en-us").
|
||||
/// Automatically computed if not specified.
|
||||
/// </summary>
|
||||
public string? ParentLocale { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a localized string by key.
|
||||
/// </summary>
|
||||
public string? GetString(string key)
|
||||
{
|
||||
return Strings.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a localized string by key with a default fallback.
|
||||
/// </summary>
|
||||
public string GetString(string key, string defaultValue)
|
||||
{
|
||||
return Strings.TryGetValue(key, out var value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
private static string? NormalizeParentLocale(string? parentLocale, string locale)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(parentLocale))
|
||||
{
|
||||
return parentLocale.ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Auto-compute parent locale from locale
|
||||
// e.g., "en-us" -> "en", "pt-br" -> "pt"
|
||||
var dashIndex = locale.IndexOf('-');
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
return locale[..dashIndex];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving localized strings with fallback chain support.
|
||||
/// </summary>
|
||||
public interface ILocalizationResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a localized string using the fallback chain.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="bundleKey">The bundle key.</param>
|
||||
/// <param name="stringKey">The string key within the bundle.</param>
|
||||
/// <param name="locale">The preferred locale.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The resolved string or null if not found.</returns>
|
||||
Task<LocalizedString?> ResolveAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
string stringKey,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves multiple strings at once for efficiency.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
IEnumerable<string> stringKeys,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a localization resolution.
|
||||
/// </summary>
|
||||
public sealed record LocalizedString
|
||||
{
|
||||
/// <summary>
|
||||
/// The resolved string value.
|
||||
/// </summary>
|
||||
public required string Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The locale that provided the value.
|
||||
/// </summary>
|
||||
public required string ResolvedLocale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The originally requested locale.
|
||||
/// </summary>
|
||||
public required string RequestedLocale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether fallback was used.
|
||||
/// </summary>
|
||||
public bool UsedFallback => !ResolvedLocale.Equals(RequestedLocale, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The fallback chain that was traversed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> FallbackChain { get; init; } = [];
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A localization bundle containing translated strings for a specific locale.
|
||||
/// </summary>
|
||||
public sealed record NotifyLocalizationBundle
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyLocalizationBundle(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string locale,
|
||||
string bundleKey,
|
||||
ImmutableDictionary<string, string> strings,
|
||||
bool isDefault = false,
|
||||
string? parentLocale = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
BundleId = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleId, nameof(bundleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Locale = NotifyValidation.EnsureNotNullOrWhiteSpace(locale, nameof(locale)).ToLowerInvariant();
|
||||
BundleKey = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleKey, nameof(bundleKey));
|
||||
Strings = strings;
|
||||
IsDefault = isDefault;
|
||||
ParentLocale = NormalizeParentLocale(parentLocale, Locale);
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyLocalizationBundle Create(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string locale,
|
||||
string bundleKey,
|
||||
IEnumerable<KeyValuePair<string, string>>? strings = null,
|
||||
bool isDefault = false,
|
||||
string? parentLocale = null,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyLocalizationBundle(
|
||||
bundleId,
|
||||
tenantId,
|
||||
locale,
|
||||
bundleKey,
|
||||
ToImmutableDictionary(strings) ?? ImmutableDictionary<string, string>.Empty,
|
||||
isDefault,
|
||||
parentLocale,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this bundle.
|
||||
/// </summary>
|
||||
public string BundleId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID this bundle belongs to.
|
||||
/// </summary>
|
||||
public string TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Locale code (e.g., "en-us", "fr-fr", "ja-jp").
|
||||
/// </summary>
|
||||
public string Locale { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle key for grouping related bundles (e.g., "notifications", "email-subjects").
|
||||
/// </summary>
|
||||
public string BundleKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary of string key to translated value.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Strings { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the default/fallback bundle for the bundle key.
|
||||
/// </summary>
|
||||
public bool IsDefault { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent locale for fallback chain (e.g., "en" for "en-us").
|
||||
/// Automatically computed if not specified.
|
||||
/// </summary>
|
||||
public string? ParentLocale { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a localized string by key.
|
||||
/// </summary>
|
||||
public string? GetString(string key)
|
||||
{
|
||||
return Strings.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a localized string by key with a default fallback.
|
||||
/// </summary>
|
||||
public string GetString(string key, string defaultValue)
|
||||
{
|
||||
return Strings.TryGetValue(key, out var value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
private static string? NormalizeParentLocale(string? parentLocale, string locale)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(parentLocale))
|
||||
{
|
||||
return parentLocale.ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Auto-compute parent locale from locale
|
||||
// e.g., "en-us" -> "en", "pt-br" -> "pt"
|
||||
var dashIndex = locale.IndexOf('-');
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
return locale[..dashIndex];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving localized strings with fallback chain support.
|
||||
/// </summary>
|
||||
public interface ILocalizationResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a localized string using the fallback chain.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="bundleKey">The bundle key.</param>
|
||||
/// <param name="stringKey">The string key within the bundle.</param>
|
||||
/// <param name="locale">The preferred locale.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The resolved string or null if not found.</returns>
|
||||
Task<LocalizedString?> ResolveAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
string stringKey,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves multiple strings at once for efficiency.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
IEnumerable<string> stringKeys,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a localization resolution.
|
||||
/// </summary>
|
||||
public sealed record LocalizedString
|
||||
{
|
||||
/// <summary>
|
||||
/// The resolved string value.
|
||||
/// </summary>
|
||||
public required string Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The locale that provided the value.
|
||||
/// </summary>
|
||||
public required string ResolvedLocale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The originally requested locale.
|
||||
/// </summary>
|
||||
public required string RequestedLocale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether fallback was used.
|
||||
/// </summary>
|
||||
public bool UsedFallback => !ResolvedLocale.Equals(RequestedLocale, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The fallback chain that was traversed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> FallbackChain { get; init; } = [];
|
||||
}
|
||||
|
||||
@@ -1,494 +1,494 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// On-call schedule defining who is on-call at any given time.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallSchedule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallSchedule(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string timeZone,
|
||||
ImmutableArray<NotifyOnCallLayer> layers,
|
||||
ImmutableArray<NotifyOnCallOverride> overrides,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone));
|
||||
Layers = layers.IsDefault ? ImmutableArray<NotifyOnCallLayer>.Empty : layers;
|
||||
Overrides = overrides.IsDefault ? ImmutableArray<NotifyOnCallOverride>.Empty : overrides;
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyOnCallSchedule Create(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string timeZone,
|
||||
IEnumerable<NotifyOnCallLayer>? layers = null,
|
||||
IEnumerable<NotifyOnCallOverride>? overrides = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyOnCallSchedule(
|
||||
scheduleId,
|
||||
tenantId,
|
||||
name,
|
||||
timeZone,
|
||||
layers?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallLayer>.Empty,
|
||||
overrides?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallOverride>.Empty,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IANA time zone for the schedule (e.g., "America/New_York").
|
||||
/// </summary>
|
||||
public string TimeZone { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rotation layers that make up this schedule.
|
||||
/// Multiple layers are combined to determine final on-call.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallLayer> Layers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Temporary overrides (e.g., vacation coverage).
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallOverride> Overrides { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A layer in an on-call schedule representing a rotation.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallLayer
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallLayer(
|
||||
string layerId,
|
||||
string name,
|
||||
int priority,
|
||||
NotifyRotationType rotationType,
|
||||
TimeSpan rotationInterval,
|
||||
DateTimeOffset rotationStartsAt,
|
||||
ImmutableArray<NotifyOnCallParticipant> participants,
|
||||
NotifyOnCallRestriction? restrictions = null)
|
||||
{
|
||||
LayerId = NotifyValidation.EnsureNotNullOrWhiteSpace(layerId, nameof(layerId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Priority = priority;
|
||||
RotationType = rotationType;
|
||||
RotationInterval = rotationInterval > TimeSpan.Zero ? rotationInterval : TimeSpan.FromDays(7);
|
||||
RotationStartsAt = NotifyValidation.EnsureUtc(rotationStartsAt);
|
||||
Participants = participants.IsDefault ? ImmutableArray<NotifyOnCallParticipant>.Empty : participants;
|
||||
Restrictions = restrictions;
|
||||
}
|
||||
|
||||
public static NotifyOnCallLayer Create(
|
||||
string layerId,
|
||||
string name,
|
||||
int priority,
|
||||
NotifyRotationType rotationType,
|
||||
TimeSpan rotationInterval,
|
||||
DateTimeOffset rotationStartsAt,
|
||||
IEnumerable<NotifyOnCallParticipant>? participants = null,
|
||||
NotifyOnCallRestriction? restrictions = null)
|
||||
{
|
||||
return new NotifyOnCallLayer(
|
||||
layerId,
|
||||
name,
|
||||
priority,
|
||||
rotationType,
|
||||
rotationInterval,
|
||||
rotationStartsAt,
|
||||
participants?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallParticipant>.Empty,
|
||||
restrictions);
|
||||
}
|
||||
|
||||
public string LayerId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Higher priority layers take precedence when determining who is on-call.
|
||||
/// </summary>
|
||||
public int Priority { get; }
|
||||
|
||||
public NotifyRotationType RotationType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of each rotation (e.g., 1 week).
|
||||
/// </summary>
|
||||
public TimeSpan RotationInterval { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the rotation schedule started.
|
||||
/// </summary>
|
||||
public DateTimeOffset RotationStartsAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Participants in the rotation.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallParticipant> Participants { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional time restrictions for when this layer is active.
|
||||
/// </summary>
|
||||
public NotifyOnCallRestriction? Restrictions { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Participant in an on-call rotation.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallParticipant
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallParticipant(
|
||||
string userId,
|
||||
string? name = null,
|
||||
string? email = null,
|
||||
string? phone = null,
|
||||
ImmutableArray<NotifyContactMethod> contactMethods = default)
|
||||
{
|
||||
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
|
||||
Name = NotifyValidation.TrimToNull(name);
|
||||
Email = NotifyValidation.TrimToNull(email);
|
||||
Phone = NotifyValidation.TrimToNull(phone);
|
||||
ContactMethods = contactMethods.IsDefault ? ImmutableArray<NotifyContactMethod>.Empty : contactMethods;
|
||||
}
|
||||
|
||||
public static NotifyOnCallParticipant Create(
|
||||
string userId,
|
||||
string? name = null,
|
||||
string? email = null,
|
||||
string? phone = null,
|
||||
IEnumerable<NotifyContactMethod>? contactMethods = null)
|
||||
{
|
||||
return new NotifyOnCallParticipant(
|
||||
userId,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
contactMethods?.ToImmutableArray() ?? ImmutableArray<NotifyContactMethod>.Empty);
|
||||
}
|
||||
|
||||
public string UserId { get; }
|
||||
|
||||
public string? Name { get; }
|
||||
|
||||
public string? Email { get; }
|
||||
|
||||
public string? Phone { get; }
|
||||
|
||||
public ImmutableArray<NotifyContactMethod> ContactMethods { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact method for a participant.
|
||||
/// </summary>
|
||||
public sealed record NotifyContactMethod
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyContactMethod(
|
||||
NotifyContactMethodType type,
|
||||
string address,
|
||||
int priority = 0,
|
||||
bool enabled = true)
|
||||
{
|
||||
Type = type;
|
||||
Address = NotifyValidation.EnsureNotNullOrWhiteSpace(address, nameof(address));
|
||||
Priority = priority;
|
||||
Enabled = enabled;
|
||||
}
|
||||
|
||||
public NotifyContactMethodType Type { get; }
|
||||
|
||||
public string Address { get; }
|
||||
|
||||
public int Priority { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of contact method.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyContactMethodType
|
||||
{
|
||||
Email,
|
||||
Sms,
|
||||
Phone,
|
||||
Slack,
|
||||
Teams,
|
||||
Webhook,
|
||||
InAppInbox,
|
||||
PagerDuty,
|
||||
OpsGenie
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of rotation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyRotationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Daily rotation.
|
||||
/// </summary>
|
||||
Daily,
|
||||
|
||||
/// <summary>
|
||||
/// Weekly rotation.
|
||||
/// </summary>
|
||||
Weekly,
|
||||
|
||||
/// <summary>
|
||||
/// Custom interval rotation.
|
||||
/// </summary>
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time restrictions for when an on-call layer is active.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallRestriction
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallRestriction(
|
||||
NotifyRestrictionType type,
|
||||
ImmutableArray<NotifyTimeRange> timeRanges)
|
||||
{
|
||||
Type = type;
|
||||
TimeRanges = timeRanges.IsDefault ? ImmutableArray<NotifyTimeRange>.Empty : timeRanges;
|
||||
}
|
||||
|
||||
public static NotifyOnCallRestriction Create(
|
||||
NotifyRestrictionType type,
|
||||
IEnumerable<NotifyTimeRange>? timeRanges = null)
|
||||
{
|
||||
return new NotifyOnCallRestriction(
|
||||
type,
|
||||
timeRanges?.ToImmutableArray() ?? ImmutableArray<NotifyTimeRange>.Empty);
|
||||
}
|
||||
|
||||
public NotifyRestrictionType Type { get; }
|
||||
|
||||
public ImmutableArray<NotifyTimeRange> TimeRanges { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of restriction.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyRestrictionType
|
||||
{
|
||||
/// <summary>
|
||||
/// Restrictions apply daily.
|
||||
/// </summary>
|
||||
DailyRestriction,
|
||||
|
||||
/// <summary>
|
||||
/// Restrictions apply weekly on specific days.
|
||||
/// </summary>
|
||||
WeeklyRestriction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A time range for restrictions.
|
||||
/// </summary>
|
||||
public sealed record NotifyTimeRange
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyTimeRange(
|
||||
DayOfWeek? dayOfWeek,
|
||||
TimeOnly startTime,
|
||||
TimeOnly endTime)
|
||||
{
|
||||
DayOfWeek = dayOfWeek;
|
||||
StartTime = startTime;
|
||||
EndTime = endTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Day of week (null for daily restrictions).
|
||||
/// </summary>
|
||||
public DayOfWeek? DayOfWeek { get; }
|
||||
|
||||
public TimeOnly StartTime { get; }
|
||||
|
||||
public TimeOnly EndTime { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Temporary override for an on-call schedule.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallOverride
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallOverride(
|
||||
string overrideId,
|
||||
string userId,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId));
|
||||
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
|
||||
StartsAt = NotifyValidation.EnsureUtc(startsAt);
|
||||
EndsAt = NotifyValidation.EnsureUtc(endsAt);
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
|
||||
if (EndsAt <= StartsAt)
|
||||
{
|
||||
throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt));
|
||||
}
|
||||
}
|
||||
|
||||
public static NotifyOnCallOverride Create(
|
||||
string overrideId,
|
||||
string userId,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new NotifyOnCallOverride(
|
||||
overrideId,
|
||||
userId,
|
||||
startsAt,
|
||||
endsAt,
|
||||
reason,
|
||||
createdBy,
|
||||
createdAt);
|
||||
}
|
||||
|
||||
public string OverrideId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// User who will be on-call during this override.
|
||||
/// </summary>
|
||||
public string UserId { get; }
|
||||
|
||||
public DateTimeOffset StartsAt { get; }
|
||||
|
||||
public DateTimeOffset EndsAt { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the override is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> timestamp >= StartsAt && timestamp < EndsAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of resolving who is currently on-call.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallResolution
|
||||
{
|
||||
public NotifyOnCallResolution(
|
||||
string scheduleId,
|
||||
DateTimeOffset evaluatedAt,
|
||||
ImmutableArray<NotifyOnCallParticipant> onCallUsers,
|
||||
string? sourceLayer = null,
|
||||
string? sourceOverride = null)
|
||||
{
|
||||
ScheduleId = scheduleId;
|
||||
EvaluatedAt = evaluatedAt;
|
||||
OnCallUsers = onCallUsers.IsDefault ? ImmutableArray<NotifyOnCallParticipant>.Empty : onCallUsers;
|
||||
SourceLayer = sourceLayer;
|
||||
SourceOverride = sourceOverride;
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public DateTimeOffset EvaluatedAt { get; }
|
||||
|
||||
public ImmutableArray<NotifyOnCallParticipant> OnCallUsers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The layer that provided the on-call user (if from rotation).
|
||||
/// </summary>
|
||||
public string? SourceLayer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The override that provided the on-call user (if from override).
|
||||
/// </summary>
|
||||
public string? SourceOverride { get; }
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// On-call schedule defining who is on-call at any given time.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallSchedule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallSchedule(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string timeZone,
|
||||
ImmutableArray<NotifyOnCallLayer> layers,
|
||||
ImmutableArray<NotifyOnCallOverride> overrides,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone));
|
||||
Layers = layers.IsDefault ? ImmutableArray<NotifyOnCallLayer>.Empty : layers;
|
||||
Overrides = overrides.IsDefault ? ImmutableArray<NotifyOnCallOverride>.Empty : overrides;
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyOnCallSchedule Create(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string timeZone,
|
||||
IEnumerable<NotifyOnCallLayer>? layers = null,
|
||||
IEnumerable<NotifyOnCallOverride>? overrides = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyOnCallSchedule(
|
||||
scheduleId,
|
||||
tenantId,
|
||||
name,
|
||||
timeZone,
|
||||
layers?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallLayer>.Empty,
|
||||
overrides?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallOverride>.Empty,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IANA time zone for the schedule (e.g., "America/New_York").
|
||||
/// </summary>
|
||||
public string TimeZone { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rotation layers that make up this schedule.
|
||||
/// Multiple layers are combined to determine final on-call.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallLayer> Layers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Temporary overrides (e.g., vacation coverage).
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallOverride> Overrides { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A layer in an on-call schedule representing a rotation.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallLayer
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallLayer(
|
||||
string layerId,
|
||||
string name,
|
||||
int priority,
|
||||
NotifyRotationType rotationType,
|
||||
TimeSpan rotationInterval,
|
||||
DateTimeOffset rotationStartsAt,
|
||||
ImmutableArray<NotifyOnCallParticipant> participants,
|
||||
NotifyOnCallRestriction? restrictions = null)
|
||||
{
|
||||
LayerId = NotifyValidation.EnsureNotNullOrWhiteSpace(layerId, nameof(layerId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Priority = priority;
|
||||
RotationType = rotationType;
|
||||
RotationInterval = rotationInterval > TimeSpan.Zero ? rotationInterval : TimeSpan.FromDays(7);
|
||||
RotationStartsAt = NotifyValidation.EnsureUtc(rotationStartsAt);
|
||||
Participants = participants.IsDefault ? ImmutableArray<NotifyOnCallParticipant>.Empty : participants;
|
||||
Restrictions = restrictions;
|
||||
}
|
||||
|
||||
public static NotifyOnCallLayer Create(
|
||||
string layerId,
|
||||
string name,
|
||||
int priority,
|
||||
NotifyRotationType rotationType,
|
||||
TimeSpan rotationInterval,
|
||||
DateTimeOffset rotationStartsAt,
|
||||
IEnumerable<NotifyOnCallParticipant>? participants = null,
|
||||
NotifyOnCallRestriction? restrictions = null)
|
||||
{
|
||||
return new NotifyOnCallLayer(
|
||||
layerId,
|
||||
name,
|
||||
priority,
|
||||
rotationType,
|
||||
rotationInterval,
|
||||
rotationStartsAt,
|
||||
participants?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallParticipant>.Empty,
|
||||
restrictions);
|
||||
}
|
||||
|
||||
public string LayerId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Higher priority layers take precedence when determining who is on-call.
|
||||
/// </summary>
|
||||
public int Priority { get; }
|
||||
|
||||
public NotifyRotationType RotationType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of each rotation (e.g., 1 week).
|
||||
/// </summary>
|
||||
public TimeSpan RotationInterval { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the rotation schedule started.
|
||||
/// </summary>
|
||||
public DateTimeOffset RotationStartsAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Participants in the rotation.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallParticipant> Participants { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional time restrictions for when this layer is active.
|
||||
/// </summary>
|
||||
public NotifyOnCallRestriction? Restrictions { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Participant in an on-call rotation.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallParticipant
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallParticipant(
|
||||
string userId,
|
||||
string? name = null,
|
||||
string? email = null,
|
||||
string? phone = null,
|
||||
ImmutableArray<NotifyContactMethod> contactMethods = default)
|
||||
{
|
||||
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
|
||||
Name = NotifyValidation.TrimToNull(name);
|
||||
Email = NotifyValidation.TrimToNull(email);
|
||||
Phone = NotifyValidation.TrimToNull(phone);
|
||||
ContactMethods = contactMethods.IsDefault ? ImmutableArray<NotifyContactMethod>.Empty : contactMethods;
|
||||
}
|
||||
|
||||
public static NotifyOnCallParticipant Create(
|
||||
string userId,
|
||||
string? name = null,
|
||||
string? email = null,
|
||||
string? phone = null,
|
||||
IEnumerable<NotifyContactMethod>? contactMethods = null)
|
||||
{
|
||||
return new NotifyOnCallParticipant(
|
||||
userId,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
contactMethods?.ToImmutableArray() ?? ImmutableArray<NotifyContactMethod>.Empty);
|
||||
}
|
||||
|
||||
public string UserId { get; }
|
||||
|
||||
public string? Name { get; }
|
||||
|
||||
public string? Email { get; }
|
||||
|
||||
public string? Phone { get; }
|
||||
|
||||
public ImmutableArray<NotifyContactMethod> ContactMethods { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact method for a participant.
|
||||
/// </summary>
|
||||
public sealed record NotifyContactMethod
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyContactMethod(
|
||||
NotifyContactMethodType type,
|
||||
string address,
|
||||
int priority = 0,
|
||||
bool enabled = true)
|
||||
{
|
||||
Type = type;
|
||||
Address = NotifyValidation.EnsureNotNullOrWhiteSpace(address, nameof(address));
|
||||
Priority = priority;
|
||||
Enabled = enabled;
|
||||
}
|
||||
|
||||
public NotifyContactMethodType Type { get; }
|
||||
|
||||
public string Address { get; }
|
||||
|
||||
public int Priority { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of contact method.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyContactMethodType
|
||||
{
|
||||
Email,
|
||||
Sms,
|
||||
Phone,
|
||||
Slack,
|
||||
Teams,
|
||||
Webhook,
|
||||
InAppInbox,
|
||||
PagerDuty,
|
||||
OpsGenie
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of rotation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyRotationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Daily rotation.
|
||||
/// </summary>
|
||||
Daily,
|
||||
|
||||
/// <summary>
|
||||
/// Weekly rotation.
|
||||
/// </summary>
|
||||
Weekly,
|
||||
|
||||
/// <summary>
|
||||
/// Custom interval rotation.
|
||||
/// </summary>
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time restrictions for when an on-call layer is active.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallRestriction
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallRestriction(
|
||||
NotifyRestrictionType type,
|
||||
ImmutableArray<NotifyTimeRange> timeRanges)
|
||||
{
|
||||
Type = type;
|
||||
TimeRanges = timeRanges.IsDefault ? ImmutableArray<NotifyTimeRange>.Empty : timeRanges;
|
||||
}
|
||||
|
||||
public static NotifyOnCallRestriction Create(
|
||||
NotifyRestrictionType type,
|
||||
IEnumerable<NotifyTimeRange>? timeRanges = null)
|
||||
{
|
||||
return new NotifyOnCallRestriction(
|
||||
type,
|
||||
timeRanges?.ToImmutableArray() ?? ImmutableArray<NotifyTimeRange>.Empty);
|
||||
}
|
||||
|
||||
public NotifyRestrictionType Type { get; }
|
||||
|
||||
public ImmutableArray<NotifyTimeRange> TimeRanges { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of restriction.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyRestrictionType
|
||||
{
|
||||
/// <summary>
|
||||
/// Restrictions apply daily.
|
||||
/// </summary>
|
||||
DailyRestriction,
|
||||
|
||||
/// <summary>
|
||||
/// Restrictions apply weekly on specific days.
|
||||
/// </summary>
|
||||
WeeklyRestriction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A time range for restrictions.
|
||||
/// </summary>
|
||||
public sealed record NotifyTimeRange
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyTimeRange(
|
||||
DayOfWeek? dayOfWeek,
|
||||
TimeOnly startTime,
|
||||
TimeOnly endTime)
|
||||
{
|
||||
DayOfWeek = dayOfWeek;
|
||||
StartTime = startTime;
|
||||
EndTime = endTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Day of week (null for daily restrictions).
|
||||
/// </summary>
|
||||
public DayOfWeek? DayOfWeek { get; }
|
||||
|
||||
public TimeOnly StartTime { get; }
|
||||
|
||||
public TimeOnly EndTime { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Temporary override for an on-call schedule.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallOverride
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallOverride(
|
||||
string overrideId,
|
||||
string userId,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId));
|
||||
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
|
||||
StartsAt = NotifyValidation.EnsureUtc(startsAt);
|
||||
EndsAt = NotifyValidation.EnsureUtc(endsAt);
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
|
||||
if (EndsAt <= StartsAt)
|
||||
{
|
||||
throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt));
|
||||
}
|
||||
}
|
||||
|
||||
public static NotifyOnCallOverride Create(
|
||||
string overrideId,
|
||||
string userId,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new NotifyOnCallOverride(
|
||||
overrideId,
|
||||
userId,
|
||||
startsAt,
|
||||
endsAt,
|
||||
reason,
|
||||
createdBy,
|
||||
createdAt);
|
||||
}
|
||||
|
||||
public string OverrideId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// User who will be on-call during this override.
|
||||
/// </summary>
|
||||
public string UserId { get; }
|
||||
|
||||
public DateTimeOffset StartsAt { get; }
|
||||
|
||||
public DateTimeOffset EndsAt { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the override is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> timestamp >= StartsAt && timestamp < EndsAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of resolving who is currently on-call.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallResolution
|
||||
{
|
||||
public NotifyOnCallResolution(
|
||||
string scheduleId,
|
||||
DateTimeOffset evaluatedAt,
|
||||
ImmutableArray<NotifyOnCallParticipant> onCallUsers,
|
||||
string? sourceLayer = null,
|
||||
string? sourceOverride = null)
|
||||
{
|
||||
ScheduleId = scheduleId;
|
||||
EvaluatedAt = evaluatedAt;
|
||||
OnCallUsers = onCallUsers.IsDefault ? ImmutableArray<NotifyOnCallParticipant>.Empty : onCallUsers;
|
||||
SourceLayer = sourceLayer;
|
||||
SourceOverride = sourceOverride;
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public DateTimeOffset EvaluatedAt { get; }
|
||||
|
||||
public ImmutableArray<NotifyOnCallParticipant> OnCallUsers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The layer that provided the on-call user (if from rotation).
|
||||
/// </summary>
|
||||
public string? SourceLayer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The override that provided the on-call user (if from override).
|
||||
/// </summary>
|
||||
public string? SourceOverride { get; }
|
||||
}
|
||||
|
||||
@@ -1,401 +1,401 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Quiet hours schedule configuration for suppressing notifications during specified periods.
|
||||
/// </summary>
|
||||
public sealed record NotifyQuietHoursSchedule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyQuietHoursSchedule(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string cronExpression,
|
||||
TimeSpan duration,
|
||||
string timeZone,
|
||||
string? channelId = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
CronExpression = NotifyValidation.EnsureNotNullOrWhiteSpace(cronExpression, nameof(cronExpression));
|
||||
Duration = duration > TimeSpan.Zero ? duration : TimeSpan.FromHours(8);
|
||||
TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone));
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyQuietHoursSchedule Create(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string cronExpression,
|
||||
TimeSpan duration,
|
||||
string timeZone,
|
||||
string? channelId = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyQuietHoursSchedule(
|
||||
scheduleId,
|
||||
tenantId,
|
||||
name,
|
||||
cronExpression,
|
||||
duration,
|
||||
timeZone,
|
||||
channelId,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Cron expression defining when quiet hours start.
|
||||
/// </summary>
|
||||
public string CronExpression { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the quiet hours window.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IANA time zone for evaluating the cron expression (e.g., "America/New_York").
|
||||
/// </summary>
|
||||
public string TimeZone { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope quiet hours to a specific channel.
|
||||
/// If null, applies to all channels.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maintenance window for planned suppression of notifications.
|
||||
/// </summary>
|
||||
public sealed record NotifyMaintenanceWindow
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyMaintenanceWindow(
|
||||
string windowId,
|
||||
string tenantId,
|
||||
string name,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
bool suppressNotifications = true,
|
||||
string? reason = null,
|
||||
ImmutableArray<string> channelIds = default,
|
||||
ImmutableArray<string> ruleIds = default,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
WindowId = NotifyValidation.EnsureNotNullOrWhiteSpace(windowId, nameof(windowId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
StartsAt = NotifyValidation.EnsureUtc(startsAt);
|
||||
EndsAt = NotifyValidation.EnsureUtc(endsAt);
|
||||
SuppressNotifications = suppressNotifications;
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
ChannelIds = NormalizeStringArray(channelIds);
|
||||
RuleIds = NormalizeStringArray(ruleIds);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
|
||||
if (EndsAt <= StartsAt)
|
||||
{
|
||||
throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt));
|
||||
}
|
||||
}
|
||||
|
||||
public static NotifyMaintenanceWindow Create(
|
||||
string windowId,
|
||||
string tenantId,
|
||||
string name,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
bool suppressNotifications = true,
|
||||
string? reason = null,
|
||||
IEnumerable<string>? channelIds = null,
|
||||
IEnumerable<string>? ruleIds = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyMaintenanceWindow(
|
||||
windowId,
|
||||
tenantId,
|
||||
name,
|
||||
startsAt,
|
||||
endsAt,
|
||||
suppressNotifications,
|
||||
reason,
|
||||
ToImmutableArray(channelIds),
|
||||
ToImmutableArray(ruleIds),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string WindowId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public DateTimeOffset StartsAt { get; }
|
||||
|
||||
public DateTimeOffset EndsAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to suppress notifications during the maintenance window.
|
||||
/// </summary>
|
||||
public bool SuppressNotifications { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the maintenance window.
|
||||
/// </summary>
|
||||
public string? Reason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of channel IDs to scope the maintenance window.
|
||||
/// If empty, applies to all channels.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ChannelIds { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of rule IDs to scope the maintenance window.
|
||||
/// If empty, applies to all rules.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RuleIds { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the maintenance window is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> SuppressNotifications && timestamp >= StartsAt && timestamp < EndsAt;
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringArray(ImmutableArray<string> values)
|
||||
{
|
||||
if (values.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(static v => v.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
|
||||
=> values is null ? ImmutableArray<string>.Empty : values.ToImmutableArray();
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator override for quiet hours or throttle configuration.
|
||||
/// Allows an operator to temporarily bypass quiet hours or throttling.
|
||||
/// </summary>
|
||||
public sealed record NotifyOperatorOverride
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOperatorOverride(
|
||||
string overrideId,
|
||||
string tenantId,
|
||||
NotifyOverrideType overrideType,
|
||||
DateTimeOffset expiresAt,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
OverrideType = overrideType;
|
||||
ExpiresAt = NotifyValidation.EnsureUtc(expiresAt);
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
RuleId = NotifyValidation.TrimToNull(ruleId);
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public static NotifyOperatorOverride Create(
|
||||
string overrideId,
|
||||
string tenantId,
|
||||
NotifyOverrideType overrideType,
|
||||
DateTimeOffset expiresAt,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new NotifyOperatorOverride(
|
||||
overrideId,
|
||||
tenantId,
|
||||
overrideType,
|
||||
expiresAt,
|
||||
channelId,
|
||||
ruleId,
|
||||
reason,
|
||||
createdBy,
|
||||
createdAt);
|
||||
}
|
||||
|
||||
public string OverrideId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public NotifyOverrideType OverrideType { get; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope the override.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional rule ID to scope the override.
|
||||
/// </summary>
|
||||
public string? RuleId { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the override is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> timestamp < ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of operator override.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyOverrideType
|
||||
{
|
||||
/// <summary>
|
||||
/// Bypass quiet hours.
|
||||
/// </summary>
|
||||
BypassQuietHours,
|
||||
|
||||
/// <summary>
|
||||
/// Bypass throttling.
|
||||
/// </summary>
|
||||
BypassThrottle,
|
||||
|
||||
/// <summary>
|
||||
/// Bypass maintenance window.
|
||||
/// </summary>
|
||||
BypassMaintenance,
|
||||
|
||||
/// <summary>
|
||||
/// Force suppress notifications.
|
||||
/// </summary>
|
||||
ForceSuppression
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Quiet hours schedule configuration for suppressing notifications during specified periods.
|
||||
/// </summary>
|
||||
public sealed record NotifyQuietHoursSchedule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyQuietHoursSchedule(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string cronExpression,
|
||||
TimeSpan duration,
|
||||
string timeZone,
|
||||
string? channelId = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
CronExpression = NotifyValidation.EnsureNotNullOrWhiteSpace(cronExpression, nameof(cronExpression));
|
||||
Duration = duration > TimeSpan.Zero ? duration : TimeSpan.FromHours(8);
|
||||
TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone));
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyQuietHoursSchedule Create(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string cronExpression,
|
||||
TimeSpan duration,
|
||||
string timeZone,
|
||||
string? channelId = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyQuietHoursSchedule(
|
||||
scheduleId,
|
||||
tenantId,
|
||||
name,
|
||||
cronExpression,
|
||||
duration,
|
||||
timeZone,
|
||||
channelId,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Cron expression defining when quiet hours start.
|
||||
/// </summary>
|
||||
public string CronExpression { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the quiet hours window.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IANA time zone for evaluating the cron expression (e.g., "America/New_York").
|
||||
/// </summary>
|
||||
public string TimeZone { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope quiet hours to a specific channel.
|
||||
/// If null, applies to all channels.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maintenance window for planned suppression of notifications.
|
||||
/// </summary>
|
||||
public sealed record NotifyMaintenanceWindow
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyMaintenanceWindow(
|
||||
string windowId,
|
||||
string tenantId,
|
||||
string name,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
bool suppressNotifications = true,
|
||||
string? reason = null,
|
||||
ImmutableArray<string> channelIds = default,
|
||||
ImmutableArray<string> ruleIds = default,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
WindowId = NotifyValidation.EnsureNotNullOrWhiteSpace(windowId, nameof(windowId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
StartsAt = NotifyValidation.EnsureUtc(startsAt);
|
||||
EndsAt = NotifyValidation.EnsureUtc(endsAt);
|
||||
SuppressNotifications = suppressNotifications;
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
ChannelIds = NormalizeStringArray(channelIds);
|
||||
RuleIds = NormalizeStringArray(ruleIds);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
|
||||
if (EndsAt <= StartsAt)
|
||||
{
|
||||
throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt));
|
||||
}
|
||||
}
|
||||
|
||||
public static NotifyMaintenanceWindow Create(
|
||||
string windowId,
|
||||
string tenantId,
|
||||
string name,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
bool suppressNotifications = true,
|
||||
string? reason = null,
|
||||
IEnumerable<string>? channelIds = null,
|
||||
IEnumerable<string>? ruleIds = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyMaintenanceWindow(
|
||||
windowId,
|
||||
tenantId,
|
||||
name,
|
||||
startsAt,
|
||||
endsAt,
|
||||
suppressNotifications,
|
||||
reason,
|
||||
ToImmutableArray(channelIds),
|
||||
ToImmutableArray(ruleIds),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string WindowId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public DateTimeOffset StartsAt { get; }
|
||||
|
||||
public DateTimeOffset EndsAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to suppress notifications during the maintenance window.
|
||||
/// </summary>
|
||||
public bool SuppressNotifications { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the maintenance window.
|
||||
/// </summary>
|
||||
public string? Reason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of channel IDs to scope the maintenance window.
|
||||
/// If empty, applies to all channels.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ChannelIds { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of rule IDs to scope the maintenance window.
|
||||
/// If empty, applies to all rules.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RuleIds { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the maintenance window is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> SuppressNotifications && timestamp >= StartsAt && timestamp < EndsAt;
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringArray(ImmutableArray<string> values)
|
||||
{
|
||||
if (values.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(static v => v.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
|
||||
=> values is null ? ImmutableArray<string>.Empty : values.ToImmutableArray();
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator override for quiet hours or throttle configuration.
|
||||
/// Allows an operator to temporarily bypass quiet hours or throttling.
|
||||
/// </summary>
|
||||
public sealed record NotifyOperatorOverride
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOperatorOverride(
|
||||
string overrideId,
|
||||
string tenantId,
|
||||
NotifyOverrideType overrideType,
|
||||
DateTimeOffset expiresAt,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
OverrideType = overrideType;
|
||||
ExpiresAt = NotifyValidation.EnsureUtc(expiresAt);
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
RuleId = NotifyValidation.TrimToNull(ruleId);
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public static NotifyOperatorOverride Create(
|
||||
string overrideId,
|
||||
string tenantId,
|
||||
NotifyOverrideType overrideType,
|
||||
DateTimeOffset expiresAt,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new NotifyOperatorOverride(
|
||||
overrideId,
|
||||
tenantId,
|
||||
overrideType,
|
||||
expiresAt,
|
||||
channelId,
|
||||
ruleId,
|
||||
reason,
|
||||
createdBy,
|
||||
createdAt);
|
||||
}
|
||||
|
||||
public string OverrideId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public NotifyOverrideType OverrideType { get; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope the override.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional rule ID to scope the override.
|
||||
/// </summary>
|
||||
public string? RuleId { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the override is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> timestamp < ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of operator override.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyOverrideType
|
||||
{
|
||||
/// <summary>
|
||||
/// Bypass quiet hours.
|
||||
/// </summary>
|
||||
BypassQuietHours,
|
||||
|
||||
/// <summary>
|
||||
/// Bypass throttling.
|
||||
/// </summary>
|
||||
BypassThrottle,
|
||||
|
||||
/// <summary>
|
||||
/// Bypass maintenance window.
|
||||
/// </summary>
|
||||
BypassMaintenance,
|
||||
|
||||
/// <summary>
|
||||
/// Force suppress notifications.
|
||||
/// </summary>
|
||||
ForceSuppression
|
||||
}
|
||||
|
||||
@@ -1,157 +1,157 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Throttle configuration for rate-limiting notifications.
|
||||
/// </summary>
|
||||
public sealed record NotifyThrottleConfig
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyThrottleConfig(
|
||||
string configId,
|
||||
string tenantId,
|
||||
string name,
|
||||
TimeSpan defaultWindow,
|
||||
int? maxNotificationsPerWindow = null,
|
||||
string? channelId = null,
|
||||
bool isDefault = false,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ConfigId = NotifyValidation.EnsureNotNullOrWhiteSpace(configId, nameof(configId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
DefaultWindow = defaultWindow > TimeSpan.Zero ? defaultWindow : TimeSpan.FromMinutes(5);
|
||||
MaxNotificationsPerWindow = maxNotificationsPerWindow > 0 ? maxNotificationsPerWindow : null;
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
IsDefault = isDefault;
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyThrottleConfig Create(
|
||||
string configId,
|
||||
string tenantId,
|
||||
string name,
|
||||
TimeSpan defaultWindow,
|
||||
int? maxNotificationsPerWindow = null,
|
||||
string? channelId = null,
|
||||
bool isDefault = false,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyThrottleConfig(
|
||||
configId,
|
||||
tenantId,
|
||||
name,
|
||||
defaultWindow,
|
||||
maxNotificationsPerWindow,
|
||||
channelId,
|
||||
isDefault,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default throttle configuration for a tenant.
|
||||
/// </summary>
|
||||
public static NotifyThrottleConfig CreateDefault(
|
||||
string tenantId,
|
||||
TimeSpan? defaultWindow = null,
|
||||
string? createdBy = null)
|
||||
{
|
||||
return Create(
|
||||
configId: $"{tenantId}-default",
|
||||
tenantId: tenantId,
|
||||
name: "Default Throttle",
|
||||
defaultWindow: defaultWindow ?? TimeSpan.FromMinutes(5),
|
||||
maxNotificationsPerWindow: null,
|
||||
channelId: null,
|
||||
isDefault: true,
|
||||
enabled: true,
|
||||
description: "Default throttle configuration for the tenant.",
|
||||
metadata: null,
|
||||
createdBy: createdBy);
|
||||
}
|
||||
|
||||
public string ConfigId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Default throttle window duration. Notifications with the same correlation key
|
||||
/// within this window will be deduplicated.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum number of notifications allowed per window.
|
||||
/// If set, additional notifications beyond this limit will be suppressed.
|
||||
/// </summary>
|
||||
public int? MaxNotificationsPerWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope the throttle configuration.
|
||||
/// If null, applies to all channels or serves as the tenant default.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the default throttle configuration for the tenant.
|
||||
/// </summary>
|
||||
public bool IsDefault { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Throttle configuration for rate-limiting notifications.
|
||||
/// </summary>
|
||||
public sealed record NotifyThrottleConfig
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyThrottleConfig(
|
||||
string configId,
|
||||
string tenantId,
|
||||
string name,
|
||||
TimeSpan defaultWindow,
|
||||
int? maxNotificationsPerWindow = null,
|
||||
string? channelId = null,
|
||||
bool isDefault = false,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ConfigId = NotifyValidation.EnsureNotNullOrWhiteSpace(configId, nameof(configId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
DefaultWindow = defaultWindow > TimeSpan.Zero ? defaultWindow : TimeSpan.FromMinutes(5);
|
||||
MaxNotificationsPerWindow = maxNotificationsPerWindow > 0 ? maxNotificationsPerWindow : null;
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
IsDefault = isDefault;
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyThrottleConfig Create(
|
||||
string configId,
|
||||
string tenantId,
|
||||
string name,
|
||||
TimeSpan defaultWindow,
|
||||
int? maxNotificationsPerWindow = null,
|
||||
string? channelId = null,
|
||||
bool isDefault = false,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyThrottleConfig(
|
||||
configId,
|
||||
tenantId,
|
||||
name,
|
||||
defaultWindow,
|
||||
maxNotificationsPerWindow,
|
||||
channelId,
|
||||
isDefault,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default throttle configuration for a tenant.
|
||||
/// </summary>
|
||||
public static NotifyThrottleConfig CreateDefault(
|
||||
string tenantId,
|
||||
TimeSpan? defaultWindow = null,
|
||||
string? createdBy = null)
|
||||
{
|
||||
return Create(
|
||||
configId: $"{tenantId}-default",
|
||||
tenantId: tenantId,
|
||||
name: "Default Throttle",
|
||||
defaultWindow: defaultWindow ?? TimeSpan.FromMinutes(5),
|
||||
maxNotificationsPerWindow: null,
|
||||
channelId: null,
|
||||
isDefault: true,
|
||||
enabled: true,
|
||||
description: "Default throttle configuration for the tenant.",
|
||||
metadata: null,
|
||||
createdBy: createdBy);
|
||||
}
|
||||
|
||||
public string ConfigId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Default throttle window duration. Notifications with the same correlation key
|
||||
/// within this window will be deduplicated.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum number of notifications allowed per window.
|
||||
/// If set, additional notifications beyond this limit will be suppressed.
|
||||
/// </summary>
|
||||
public int? MaxNotificationsPerWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope the throttle configuration.
|
||||
/// If null, applies to all channels or serves as the tenant default.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the default throttle configuration for the tenant.
|
||||
/// </summary>
|
||||
public bool IsDefault { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user