feat(notifier): postgres escalation + on-call schedule compat

Sprint SPRINT_20260416_009_Notify_truthful_escalation_oncall_runtime.

PostgresEscalationRuntimeServices plus Notify + Notifier WebService
compat shims for escalation policy and on-call schedule service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-19 14:39:47 +03:00
parent b877e13b3c
commit 9148c088da
4 changed files with 1624 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Escalation;
using System.Collections.Concurrent;
using System.Linq;
@@ -74,3 +75,125 @@ public sealed class InMemoryEscalationPolicyRepository : INotifyEscalationPolicy
private ConcurrentDictionary<string, NotifyEscalationPolicy> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyEscalationPolicy>());
}
public sealed class ServiceBackedNotifyEscalationPolicyRepository : INotifyEscalationPolicyRepository
{
private readonly IEscalationPolicyService _policyService;
public ServiceBackedNotifyEscalationPolicyRepository(IEscalationPolicyService policyService)
{
_policyService = policyService;
}
public async Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
string tenantId,
string? policyType,
CancellationToken cancellationToken = default)
{
return (await _policyService.ListPoliciesAsync(tenantId, cancellationToken).ConfigureAwait(false))
.Select(MapPolicy)
.ToList();
}
public async Task<NotifyEscalationPolicy?> GetAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
var policy = await _policyService.GetPolicyAsync(tenantId, policyId, cancellationToken).ConfigureAwait(false);
return policy is null ? null : MapPolicy(policy);
}
public async Task<NotifyEscalationPolicy> UpsertAsync(
NotifyEscalationPolicy policy,
CancellationToken cancellationToken = default)
{
var persisted = await _policyService.UpsertPolicyAsync(MapPolicy(policy), actor: policy.UpdatedBy ?? policy.CreatedBy, cancellationToken).ConfigureAwait(false);
return MapPolicy(persisted);
}
public Task<bool> DeleteAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default)
=> _policyService.DeletePolicyAsync(tenantId, policyId, actor: null, cancellationToken);
private static NotifyEscalationPolicy MapPolicy(EscalationPolicy policy)
{
return NotifyEscalationPolicy.Create(
policyId: policy.PolicyId,
tenantId: policy.TenantId,
name: policy.Name,
levels: policy.Levels.Select(level => NotifyEscalationLevel.Create(
order: level.Level,
escalateAfter: level.EscalateAfter,
targets: level.Targets.Select(target => NotifyEscalationTarget.Create(
MapTargetType(target.Type),
target.TargetId,
target.ChannelId)),
name: level.Name,
notifyAll: level.NotifyMode != EscalationNotifyMode.Sequential)),
enabled: policy.Enabled,
repeatEnabled: policy.ExhaustedAction != EscalationExhaustedAction.Stop,
repeatCount: policy.MaxCycles > 0 ? policy.MaxCycles : null,
description: policy.Description,
createdBy: policy.CreatedBy,
createdAt: policy.CreatedAt,
updatedBy: policy.UpdatedBy,
updatedAt: policy.UpdatedAt);
}
private static EscalationPolicy MapPolicy(NotifyEscalationPolicy policy)
{
return new EscalationPolicy
{
PolicyId = policy.PolicyId,
TenantId = policy.TenantId,
Name = policy.Name,
Description = policy.Description,
IsDefault = false,
Enabled = policy.Enabled,
EventKinds = null,
MinSeverity = null,
Levels = policy.Levels.Select(level => new EscalationLevel
{
Level = level.Order,
Name = level.Name,
EscalateAfter = level.EscalateAfter,
Targets = level.Targets.Select(target => new EscalationTarget
{
Type = MapTargetType(target.Type),
TargetId = target.TargetId,
Name = null,
ChannelId = target.ChannelOverride
}).ToList(),
NotifyMode = level.NotifyAll ? EscalationNotifyMode.All : EscalationNotifyMode.Sequential,
StopOnAck = true
}).ToList(),
ExhaustedAction = policy.RepeatEnabled ? EscalationExhaustedAction.RepeatLastLevel : EscalationExhaustedAction.Stop,
MaxCycles = policy.RepeatCount ?? 0,
CreatedAt = policy.CreatedAt,
CreatedBy = policy.CreatedBy,
UpdatedAt = policy.UpdatedAt,
UpdatedBy = policy.UpdatedBy
};
}
private static NotifyEscalationTargetType MapTargetType(EscalationTargetType type)
=> type switch
{
EscalationTargetType.OnCallSchedule => NotifyEscalationTargetType.OnCallSchedule,
EscalationTargetType.Channel => NotifyEscalationTargetType.Channel,
EscalationTargetType.Integration => NotifyEscalationTargetType.ExternalService,
_ => NotifyEscalationTargetType.User
};
private static EscalationTargetType MapTargetType(NotifyEscalationTargetType type)
=> type switch
{
NotifyEscalationTargetType.OnCallSchedule => EscalationTargetType.OnCallSchedule,
NotifyEscalationTargetType.Channel => EscalationTargetType.Channel,
NotifyEscalationTargetType.ExternalService => EscalationTargetType.Integration,
_ => EscalationTargetType.User
};
}

View File

@@ -0,0 +1,283 @@
using System.Collections.Immutable;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Escalation;
namespace StellaOps.Notifier.WebService.Storage.Compat;
public sealed class ServiceBackedNotifyOnCallScheduleRepository : INotifyOnCallScheduleRepository
{
private readonly IOnCallScheduleService _scheduleService;
public ServiceBackedNotifyOnCallScheduleRepository(IOnCallScheduleService scheduleService)
{
_scheduleService = scheduleService ?? throw new ArgumentNullException(nameof(scheduleService));
}
public async Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(
string tenantId,
bool? includeInactive,
CancellationToken cancellationToken = default)
{
IEnumerable<NotifyOnCallSchedule> schedules = (await _scheduleService
.ListSchedulesAsync(tenantId, cancellationToken)
.ConfigureAwait(false))
.Select(MapSchedule);
if (includeInactive is not true)
{
var now = DateTimeOffset.UtcNow;
schedules = schedules.Where(schedule =>
schedule.Overrides.Any(ovr => ovr.IsActiveAt(now)) ||
!schedule.Overrides.Any());
}
return schedules
.OrderBy(schedule => schedule.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public async Task<NotifyOnCallSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var schedule = await _scheduleService.GetScheduleAsync(tenantId, scheduleId, cancellationToken).ConfigureAwait(false);
return schedule is null ? null : MapSchedule(schedule);
}
public async Task<NotifyOnCallSchedule> UpsertAsync(
NotifyOnCallSchedule schedule,
CancellationToken cancellationToken = default)
{
var persisted = await _scheduleService
.UpsertScheduleAsync(MapSchedule(schedule), schedule.UpdatedBy ?? schedule.CreatedBy, cancellationToken)
.ConfigureAwait(false);
return MapSchedule(persisted);
}
public Task<bool> DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
=> _scheduleService.DeleteScheduleAsync(tenantId, scheduleId, actor: null, cancellationToken);
public Task AddOverrideAsync(
string tenantId,
string scheduleId,
NotifyOnCallOverride @override,
CancellationToken cancellationToken = default)
=> _scheduleService.CreateOverrideAsync(
tenantId,
scheduleId,
MapOverride(@override),
@override.CreatedBy,
cancellationToken);
public Task<bool> RemoveOverrideAsync(
string tenantId,
string scheduleId,
string overrideId,
CancellationToken cancellationToken = default)
=> _scheduleService.DeleteOverrideAsync(tenantId, scheduleId, overrideId, actor: null, cancellationToken);
private static NotifyOnCallSchedule MapSchedule(OnCallSchedule schedule)
{
return NotifyOnCallSchedule.Create(
scheduleId: schedule.ScheduleId,
tenantId: schedule.TenantId,
name: schedule.Name,
timeZone: schedule.Timezone,
layers: schedule.Layers.Select((layer, index) => NotifyOnCallLayer.Create(
layerId: $"layer-{index + 1}",
name: layer.Name,
priority: layer.Priority,
rotationType: MapRotationType(layer.Type),
rotationInterval: layer.RotationInterval,
rotationStartsAt: layer.RotationStart,
participants: layer.Users.Select(MapParticipant),
restrictions: MapRestrictions(layer.Restrictions))),
overrides: schedule.Overrides?.Select(MapOverride),
enabled: schedule.Enabled,
description: schedule.Description,
createdBy: schedule.CreatedBy,
createdAt: schedule.CreatedAt,
updatedBy: schedule.UpdatedBy,
updatedAt: schedule.UpdatedAt);
}
private static OnCallSchedule MapSchedule(NotifyOnCallSchedule schedule)
{
return new OnCallSchedule
{
ScheduleId = schedule.ScheduleId,
TenantId = schedule.TenantId,
Name = schedule.Name,
Description = schedule.Description,
Timezone = schedule.TimeZone,
Enabled = schedule.Enabled,
Layers = schedule.Layers.Select(MapLayer).ToList(),
Overrides = schedule.Overrides.Select(MapOverride).ToList(),
CreatedAt = schedule.CreatedAt,
CreatedBy = schedule.CreatedBy,
UpdatedAt = schedule.UpdatedAt,
UpdatedBy = schedule.UpdatedBy
};
}
private static RotationLayer MapLayer(NotifyOnCallLayer layer)
{
return new RotationLayer
{
Name = layer.Name,
Priority = layer.Priority,
Users = layer.Participants.Select((participant, index) => new OnCallUser
{
UserId = participant.UserId,
Name = participant.Name ?? participant.UserId,
Email = participant.Email,
Phone = participant.Phone,
PreferredChannelId = null,
Order = index
}).ToList(),
Type = MapRotationType(layer.RotationType),
HandoffTime = TimeOnly.FromDateTime(layer.RotationStartsAt.UtcDateTime),
RotationInterval = layer.RotationInterval,
RotationStart = layer.RotationStartsAt,
Restrictions = MapRestrictions(layer.Restrictions),
Enabled = true
};
}
private static NotifyOnCallParticipant MapParticipant(OnCallUser user)
{
var contactMethods = new List<NotifyContactMethod>();
if (!string.IsNullOrWhiteSpace(user.Email))
{
contactMethods.Add(new NotifyContactMethod(NotifyContactMethodType.Email, user.Email));
}
if (!string.IsNullOrWhiteSpace(user.Phone))
{
contactMethods.Add(new NotifyContactMethod(NotifyContactMethodType.Phone, user.Phone));
}
return NotifyOnCallParticipant.Create(
userId: user.UserId,
name: user.Name,
email: user.Email,
phone: user.Phone,
contactMethods: contactMethods);
}
private static NotifyOnCallOverride MapOverride(OnCallOverride @override)
{
return NotifyOnCallOverride.Create(
overrideId: @override.OverrideId,
userId: @override.User.UserId,
startsAt: @override.StartsAt,
endsAt: @override.EndsAt,
reason: @override.Reason,
createdBy: @override.CreatedBy,
createdAt: @override.CreatedAt);
}
private static OnCallOverride MapOverride(NotifyOnCallOverride @override)
{
return new OnCallOverride
{
OverrideId = @override.OverrideId,
User = new OnCallUser
{
UserId = @override.UserId,
Name = @override.UserId,
Order = 0
},
StartsAt = @override.StartsAt,
EndsAt = @override.EndsAt,
Reason = @override.Reason,
CreatedBy = @override.CreatedBy,
CreatedAt = @override.CreatedAt
};
}
private static NotifyRotationType MapRotationType(RotationType type)
=> type switch
{
RotationType.Daily => NotifyRotationType.Daily,
RotationType.Custom => NotifyRotationType.Custom,
_ => NotifyRotationType.Weekly
};
private static RotationType MapRotationType(NotifyRotationType type)
=> type switch
{
NotifyRotationType.Daily => RotationType.Daily,
NotifyRotationType.Custom => RotationType.Custom,
_ => RotationType.Weekly
};
private static NotifyOnCallRestriction? MapRestrictions(IReadOnlyList<ScheduleRestriction>? restrictions)
{
if (restrictions is not { Count: > 0 })
{
return null;
}
var hasWeeklyRestriction = restrictions.Any(restriction =>
restriction.Type is RestrictionType.DaysOfWeek or RestrictionType.DaysAndTime);
var timeRanges = restrictions
.SelectMany(MapTimeRanges)
.ToImmutableArray();
return NotifyOnCallRestriction.Create(
hasWeeklyRestriction ? NotifyRestrictionType.WeeklyRestriction : NotifyRestrictionType.DailyRestriction,
timeRanges);
}
private static IReadOnlyList<ScheduleRestriction>? MapRestrictions(NotifyOnCallRestriction? restriction)
{
if (restriction is null || restriction.TimeRanges.IsDefaultOrEmpty)
{
return null;
}
return restriction.TimeRanges
.Select(timeRange =>
{
var hasDay = timeRange.DayOfWeek.HasValue;
return new ScheduleRestriction
{
Type = restriction.Type switch
{
NotifyRestrictionType.DailyRestriction => RestrictionType.TimeOfDay,
NotifyRestrictionType.WeeklyRestriction when hasDay => RestrictionType.DaysAndTime,
_ => RestrictionType.DaysOfWeek
},
DaysOfWeek = hasDay ? [ (int)timeRange.DayOfWeek!.Value ] : null,
StartTime = timeRange.StartTime,
EndTime = timeRange.EndTime
};
})
.ToList();
}
private static IEnumerable<NotifyTimeRange> MapTimeRanges(ScheduleRestriction restriction)
{
var startTime = restriction.StartTime ?? TimeOnly.MinValue;
var endTime = restriction.EndTime ?? TimeOnly.MaxValue;
if (restriction.DaysOfWeek is { Count: > 0 })
{
foreach (var day in restriction.DaysOfWeek)
{
yield return new NotifyTimeRange((DayOfWeek)day, startTime, endTime);
}
yield break;
}
yield return new NotifyTimeRange(null, startTime, endTime);
}
}

View File

@@ -0,0 +1,810 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using WorkerAuditRepository = StellaOps.Notifier.Worker.Storage.INotifyAuditRepository;
namespace StellaOps.Notifier.Worker.Escalation;
public sealed class PostgresEscalationPolicyService : IEscalationPolicyService
{
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private static readonly string[] SeverityOrder = ["low", "medium", "high", "critical"];
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PostgresEscalationPolicyService> _logger;
public PostgresEscalationPolicyService(
IServiceScopeFactory scopeFactory,
TimeProvider timeProvider,
ILogger<PostgresEscalationPolicyService> logger)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyList<EscalationPolicy>> ListPoliciesAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IEscalationPolicyRepository>();
return (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false))
.Select(MapPolicy)
.OrderByDescending(policy => policy.IsDefault)
.ThenBy(policy => policy.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public async Task<EscalationPolicy?> GetPolicyAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IEscalationPolicyRepository>();
var entity = await FindPolicyEntityAsync(repository, tenantId, policyId, cancellationToken).ConfigureAwait(false);
return entity is null ? null : MapPolicy(entity);
}
public async Task<EscalationPolicy> UpsertPolicyAsync(
EscalationPolicy policy,
string? actor,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(policy);
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IEscalationPolicyRepository>();
var auditRepository = scope.ServiceProvider.GetService<WorkerAuditRepository>();
var existing = await FindPolicyEntityAsync(repository, policy.TenantId, policy.PolicyId, cancellationToken).ConfigureAwait(false);
var existingMetadata = existing is null ? null : DeserializePolicyMetadata(existing.Metadata);
var now = _timeProvider.GetUtcNow();
var isNew = existing is null;
if (policy.IsDefault)
{
var tenantPolicies = await repository.ListAsync(policy.TenantId, cancellationToken).ConfigureAwait(false);
foreach (var candidate in tenantPolicies)
{
var candidateMetadata = DeserializePolicyMetadata(candidate.Metadata);
if (!candidateMetadata.IsDefault)
{
continue;
}
var candidateExternalId = GetPolicyExternalId(candidate, candidateMetadata);
if (string.Equals(candidateExternalId, policy.PolicyId, StringComparison.Ordinal))
{
continue;
}
var updatedCandidate = new EscalationPolicyEntity
{
Id = candidate.Id,
TenantId = candidate.TenantId,
Name = candidate.Name,
Description = candidate.Description,
Enabled = candidate.Enabled,
Steps = candidate.Steps,
RepeatCount = candidate.RepeatCount,
Metadata = Serialize(new PersistedEscalationPolicyMetadata(
candidateExternalId,
false,
candidateMetadata.EventKinds,
candidateMetadata.MinSeverity,
candidateMetadata.ExhaustedAction,
candidateMetadata.CreatedBy,
actor)),
CreatedAt = candidate.CreatedAt,
UpdatedAt = now
};
await repository.UpdateAsync(updatedCandidate, cancellationToken).ConfigureAwait(false);
}
}
var createdAt = existing?.CreatedAt ?? policy.CreatedAt;
if (createdAt == default)
{
createdAt = now;
}
var entity = new EscalationPolicyEntity
{
Id = existing?.Id ?? Guid.NewGuid(),
TenantId = policy.TenantId,
Name = policy.Name,
Description = policy.Description,
Enabled = policy.Enabled,
Steps = Serialize(policy.Levels),
RepeatCount = policy.MaxCycles,
Metadata = Serialize(new PersistedEscalationPolicyMetadata(
policy.PolicyId,
policy.IsDefault,
policy.EventKinds?.ToList(),
policy.MinSeverity,
policy.ExhaustedAction.ToString(),
existingMetadata?.CreatedBy ?? policy.CreatedBy ?? actor,
actor)),
CreatedAt = createdAt,
UpdatedAt = now
};
if (isNew)
{
await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
}
else
{
await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
}
if (auditRepository is not null)
{
await auditRepository.AppendAsync(
policy.TenantId,
isNew ? "escalation_policy_created" : "escalation_policy_updated",
actor,
new Dictionary<string, string>
{
["policyId"] = policy.PolicyId,
["name"] = policy.Name,
["enabled"] = policy.Enabled.ToString(),
["isDefault"] = policy.IsDefault.ToString(),
["levelCount"] = policy.Levels.Count.ToString()
},
cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation(
"{Action} escalation policy {PolicyId} for tenant {TenantId}.",
isNew ? "Created" : "Updated",
policy.PolicyId,
policy.TenantId);
return MapPolicy(entity);
}
public async Task<bool> DeletePolicyAsync(
string tenantId,
string policyId,
string? actor,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IEscalationPolicyRepository>();
var auditRepository = scope.ServiceProvider.GetService<WorkerAuditRepository>();
var existing = await FindPolicyEntityAsync(repository, tenantId, policyId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return false;
}
var deleted = await repository.DeleteAsync(tenantId, existing.Id, cancellationToken).ConfigureAwait(false);
if (deleted && auditRepository is not null)
{
await auditRepository.AppendAsync(
tenantId,
"escalation_policy_deleted",
actor,
new Dictionary<string, string> { ["policyId"] = policyId },
cancellationToken).ConfigureAwait(false);
}
return deleted;
}
public async Task<EscalationPolicy?> GetDefaultPolicyAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var policies = await ListPoliciesAsync(tenantId, cancellationToken).ConfigureAwait(false);
return policies.FirstOrDefault(policy => policy.IsDefault && policy.Enabled);
}
public async Task<IReadOnlyList<EscalationPolicy>> FindMatchingPoliciesAsync(
string tenantId,
string eventKind,
string? severity,
CancellationToken cancellationToken = default)
{
var policies = await ListPoliciesAsync(tenantId, cancellationToken).ConfigureAwait(false);
return policies
.Where(policy => policy.Enabled)
.Where(policy => MatchesEventKind(policy, eventKind))
.Where(policy => MatchesSeverity(policy, severity))
.OrderByDescending(policy => policy.IsDefault)
.ThenBy(policy => policy.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static bool MatchesEventKind(EscalationPolicy policy, string eventKind)
{
if (policy.EventKinds is null || policy.EventKinds.Count == 0)
{
return true;
}
return policy.EventKinds.Any(kind =>
eventKind.StartsWith(kind, StringComparison.OrdinalIgnoreCase) ||
string.Equals(kind, "*", StringComparison.Ordinal));
}
private static bool MatchesSeverity(EscalationPolicy policy, string? severity)
{
if (string.IsNullOrWhiteSpace(policy.MinSeverity) || string.IsNullOrWhiteSpace(severity))
{
return true;
}
var minIndex = Array.FindIndex(
SeverityOrder,
candidate => candidate.Equals(policy.MinSeverity, StringComparison.OrdinalIgnoreCase));
var actualIndex = Array.FindIndex(
SeverityOrder,
candidate => candidate.Equals(severity, StringComparison.OrdinalIgnoreCase));
if (minIndex < 0 || actualIndex < 0)
{
return true;
}
return actualIndex >= minIndex;
}
private static EscalationPolicy MapPolicy(EscalationPolicyEntity entity)
{
var metadata = DeserializePolicyMetadata(entity.Metadata);
var externalId = GetPolicyExternalId(entity, metadata);
var levels = Deserialize<List<EscalationLevel>>(entity.Steps) ?? [];
return new EscalationPolicy
{
PolicyId = externalId,
TenantId = entity.TenantId,
Name = entity.Name,
Description = entity.Description,
IsDefault = metadata.IsDefault,
Enabled = entity.Enabled,
EventKinds = metadata.EventKinds,
MinSeverity = metadata.MinSeverity,
Levels = levels,
ExhaustedAction = Enum.TryParse<EscalationExhaustedAction>(metadata.ExhaustedAction, true, out var action)
? action
: EscalationExhaustedAction.RepeatLastLevel,
MaxCycles = entity.RepeatCount,
CreatedAt = entity.CreatedAt,
CreatedBy = metadata.CreatedBy,
UpdatedAt = entity.UpdatedAt,
UpdatedBy = metadata.UpdatedBy
};
}
private static async Task<EscalationPolicyEntity?> FindPolicyEntityAsync(
IEscalationPolicyRepository repository,
string tenantId,
string policyId,
CancellationToken cancellationToken)
{
foreach (var entity in await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false))
{
var metadata = DeserializePolicyMetadata(entity.Metadata);
var externalId = GetPolicyExternalId(entity, metadata);
if (string.Equals(externalId, policyId, StringComparison.Ordinal))
{
return entity;
}
}
return null;
}
private static PersistedEscalationPolicyMetadata DeserializePolicyMetadata(string json)
=> Deserialize<PersistedEscalationPolicyMetadata>(json) ?? new PersistedEscalationPolicyMetadata(null, false, null, null, null, null, null);
private static string GetPolicyExternalId(EscalationPolicyEntity entity, PersistedEscalationPolicyMetadata metadata)
=> string.IsNullOrWhiteSpace(metadata.ExternalId) ? entity.Id.ToString("N") : metadata.ExternalId;
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.Converters.Add(new JsonStringEnumConverter());
return options;
}
private static string Serialize<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
private static T? Deserialize<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
private sealed record PersistedEscalationPolicyMetadata(
string? ExternalId,
bool IsDefault,
List<string>? EventKinds,
string? MinSeverity,
string? ExhaustedAction,
string? CreatedBy,
string? UpdatedBy);
}
public sealed class PostgresOnCallScheduleService : IOnCallScheduleService
{
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PostgresOnCallScheduleService> _logger;
public PostgresOnCallScheduleService(
IServiceScopeFactory scopeFactory,
TimeProvider timeProvider,
ILogger<PostgresOnCallScheduleService> logger)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyList<OnCallSchedule>> ListSchedulesAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IOnCallScheduleRepository>();
return (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false))
.Select(MapSchedule)
.OrderBy(schedule => schedule.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public async Task<OnCallSchedule?> GetScheduleAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IOnCallScheduleRepository>();
var entity = await FindScheduleEntityAsync(repository, tenantId, scheduleId, cancellationToken).ConfigureAwait(false);
return entity is null ? null : MapSchedule(entity);
}
public async Task<OnCallSchedule> UpsertScheduleAsync(
OnCallSchedule schedule,
string? actor,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(schedule);
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IOnCallScheduleRepository>();
var existing = await FindScheduleEntityAsync(repository, schedule.TenantId, schedule.ScheduleId, cancellationToken).ConfigureAwait(false);
var existingMetadata = existing is null ? null : DeserializeScheduleMetadata(existing.Metadata);
var now = _timeProvider.GetUtcNow();
var createdAt = existing?.CreatedAt ?? schedule.CreatedAt;
if (createdAt == default)
{
createdAt = now;
}
var entity = new OnCallScheduleEntity
{
Id = existing?.Id ?? Guid.NewGuid(),
TenantId = schedule.TenantId,
Name = schedule.Name,
Description = schedule.Description,
Timezone = schedule.Timezone,
RotationType = MapRotationType(schedule.Layers),
Participants = Serialize(schedule.Layers),
Overrides = Serialize(schedule.Overrides ?? Array.Empty<OnCallOverride>()),
Metadata = Serialize(new PersistedOnCallScheduleMetadata(
schedule.ScheduleId,
schedule.Enabled,
existingMetadata?.CreatedBy ?? schedule.CreatedBy ?? actor,
actor)),
CreatedAt = createdAt,
UpdatedAt = now
};
if (existing is null)
{
await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
}
else
{
await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation(
"{Action} on-call schedule {ScheduleId} for tenant {TenantId}.",
existing is null ? "Created" : "Updated",
schedule.ScheduleId,
schedule.TenantId);
return MapSchedule(entity);
}
public async Task<bool> DeleteScheduleAsync(
string tenantId,
string scheduleId,
string? actor,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IOnCallScheduleRepository>();
var entity = await FindScheduleEntityAsync(repository, tenantId, scheduleId, cancellationToken).ConfigureAwait(false);
return entity is not null && await repository.DeleteAsync(tenantId, entity.Id, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OnCallUser>> GetCurrentOnCallAsync(
string tenantId,
string scheduleId,
DateTimeOffset? atTime = null,
CancellationToken cancellationToken = default)
{
var schedule = await GetScheduleAsync(tenantId, scheduleId, cancellationToken).ConfigureAwait(false);
if (schedule is null || !schedule.Enabled)
{
return [];
}
var now = atTime ?? _timeProvider.GetUtcNow();
if (schedule.Overrides is { Count: > 0 })
{
var activeOverride = schedule.Overrides
.Where(candidate => candidate.StartsAt <= now && candidate.EndsAt > now)
.OrderBy(candidate => candidate.StartsAt)
.FirstOrDefault();
if (activeOverride is not null)
{
return [activeOverride.User];
}
}
var users = new List<OnCallUser>();
foreach (var layer in schedule.Layers.Where(layer => layer.Enabled).OrderBy(layer => layer.Priority))
{
if (!IsLayerActiveAt(layer, now, schedule.Timezone))
{
continue;
}
var user = GetOnCallUserForLayer(layer, now);
if (user is not null)
{
users.Add(user);
}
}
return users;
}
public async Task<IReadOnlyList<OnCallShift>> GetCoverageAsync(
string tenantId,
string scheduleId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default)
{
var schedule = await GetScheduleAsync(tenantId, scheduleId, cancellationToken).ConfigureAwait(false);
if (schedule is null)
{
return [];
}
var shifts = new List<OnCallShift>();
if (schedule.Overrides is { Count: > 0 })
{
foreach (var currentOverride in schedule.Overrides.Where(candidate => candidate.EndsAt > from && candidate.StartsAt < to))
{
shifts.Add(new OnCallShift
{
User = currentOverride.User,
StartsAt = currentOverride.StartsAt < from ? from : currentOverride.StartsAt,
EndsAt = currentOverride.EndsAt > to ? to : currentOverride.EndsAt,
LayerName = "Override",
IsOverride = true
});
}
}
foreach (var layer in schedule.Layers.Where(layer => layer.Enabled).OrderBy(layer => layer.Priority))
{
shifts.AddRange(CalculateLayerShifts(layer, from, to, schedule.Timezone));
}
return shifts.OrderBy(shift => shift.StartsAt).ToList();
}
public async Task<OnCallOverride> CreateOverrideAsync(
string tenantId,
string scheduleId,
OnCallOverride @override,
string? actor,
CancellationToken cancellationToken = default)
{
var schedule = await GetScheduleAsync(tenantId, scheduleId, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException($"Schedule {scheduleId} not found.");
var now = _timeProvider.GetUtcNow();
var createdOverride = @override with
{
OverrideId = string.IsNullOrWhiteSpace(@override.OverrideId) ? $"ovr-{Guid.NewGuid():N}"[..16] : @override.OverrideId,
CreatedAt = now,
CreatedBy = actor
};
var updatedSchedule = schedule with
{
Overrides = schedule.Overrides is { Count: > 0 }
? schedule.Overrides.Concat([createdOverride]).ToList()
: [createdOverride],
UpdatedAt = now,
UpdatedBy = actor
};
await UpsertScheduleAsync(updatedSchedule, actor, cancellationToken).ConfigureAwait(false);
return createdOverride;
}
public async Task<bool> DeleteOverrideAsync(
string tenantId,
string scheduleId,
string overrideId,
string? actor,
CancellationToken cancellationToken = default)
{
var schedule = await GetScheduleAsync(tenantId, scheduleId, cancellationToken).ConfigureAwait(false);
if (schedule is null)
{
return false;
}
var updatedOverrides = schedule.Overrides?.Where(current => !string.Equals(current.OverrideId, overrideId, StringComparison.Ordinal)).ToList()
?? [];
if (schedule.Overrides?.Count == updatedOverrides.Count)
{
return false;
}
var updatedSchedule = schedule with
{
Overrides = updatedOverrides,
UpdatedAt = _timeProvider.GetUtcNow(),
UpdatedBy = actor
};
await UpsertScheduleAsync(updatedSchedule, actor, cancellationToken).ConfigureAwait(false);
return true;
}
private static OnCallSchedule MapSchedule(OnCallScheduleEntity entity)
{
var metadata = DeserializeScheduleMetadata(entity.Metadata);
var externalId = GetScheduleExternalId(entity, metadata);
var layers = Deserialize<List<RotationLayer>>(entity.Participants) ?? [];
var overrides = Deserialize<List<OnCallOverride>>(entity.Overrides) ?? [];
return new OnCallSchedule
{
ScheduleId = externalId,
TenantId = entity.TenantId,
Name = entity.Name,
Description = entity.Description,
Timezone = entity.Timezone,
Enabled = metadata.Enabled,
Layers = layers,
Overrides = overrides,
CreatedAt = entity.CreatedAt,
CreatedBy = metadata.CreatedBy,
UpdatedAt = entity.UpdatedAt,
UpdatedBy = metadata.UpdatedBy
};
}
private static async Task<OnCallScheduleEntity?> FindScheduleEntityAsync(
IOnCallScheduleRepository repository,
string tenantId,
string scheduleId,
CancellationToken cancellationToken)
{
foreach (var entity in await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false))
{
var metadata = DeserializeScheduleMetadata(entity.Metadata);
var externalId = GetScheduleExternalId(entity, metadata);
if (string.Equals(externalId, scheduleId, StringComparison.Ordinal))
{
return entity;
}
}
return null;
}
private bool IsLayerActiveAt(RotationLayer layer, DateTimeOffset time, string timezone)
{
if (layer.Restrictions is null || layer.Restrictions.Count == 0)
{
return true;
}
var zone = ResolveTimeZone(timezone);
var localTime = TimeZoneInfo.ConvertTime(time, zone);
var currentDay = (int)localTime.DayOfWeek;
var currentTimeOfDay = TimeOnly.FromDateTime(localTime.DateTime);
foreach (var restriction in layer.Restrictions)
{
switch (restriction.Type)
{
case RestrictionType.DaysOfWeek:
if (restriction.DaysOfWeek?.Contains(currentDay) != true)
{
return false;
}
break;
case RestrictionType.TimeOfDay:
if (restriction.StartTime.HasValue && restriction.EndTime.HasValue && !IsWithinTimeWindow(currentTimeOfDay, restriction.StartTime.Value, restriction.EndTime.Value))
{
return false;
}
break;
case RestrictionType.DaysAndTime:
if (restriction.DaysOfWeek?.Contains(currentDay) != true)
{
return false;
}
if (restriction.StartTime.HasValue && restriction.EndTime.HasValue && !IsWithinTimeWindow(currentTimeOfDay, restriction.StartTime.Value, restriction.EndTime.Value))
{
return false;
}
break;
}
}
return true;
}
private static bool IsWithinTimeWindow(TimeOnly current, TimeOnly start, TimeOnly end)
{
if (start <= end)
{
return current >= start && current < end;
}
return current >= start || current < end;
}
private static OnCallUser? GetOnCallUserForLayer(RotationLayer layer, DateTimeOffset now)
{
if (layer.Users.Count == 0)
{
return null;
}
var orderedUsers = layer.Users.OrderBy(user => user.Order).ToList();
var elapsed = now - layer.RotationStart;
var rotationCount = (int)(elapsed / layer.RotationInterval);
var userIndex = rotationCount % orderedUsers.Count;
return orderedUsers[userIndex >= 0 ? userIndex : 0];
}
private List<OnCallShift> CalculateLayerShifts(RotationLayer layer, DateTimeOffset from, DateTimeOffset to, string timezone)
{
var shifts = new List<OnCallShift>();
if (layer.Users.Count == 0)
{
return shifts;
}
var orderedUsers = layer.Users.OrderBy(user => user.Order).ToList();
var elapsed = from - layer.RotationStart;
var rotationsBeforeFrom = (int)(elapsed / layer.RotationInterval);
var currentStart = layer.RotationStart + (rotationsBeforeFrom * layer.RotationInterval);
while (currentStart < to)
{
var currentEnd = currentStart + layer.RotationInterval;
if (currentEnd > to)
{
currentEnd = to;
}
var rotationIndex = rotationsBeforeFrom % orderedUsers.Count;
var user = orderedUsers[rotationIndex >= 0 ? rotationIndex : 0];
if (currentEnd > from)
{
shifts.Add(new OnCallShift
{
User = user,
StartsAt = currentStart < from ? from : currentStart,
EndsAt = currentEnd,
LayerName = layer.Name,
IsOverride = false
});
}
currentStart = currentEnd;
rotationsBeforeFrom++;
}
return shifts;
}
private static string MapRotationType(IReadOnlyList<RotationLayer> layers)
{
if (layers.Count == 0)
{
return StellaOps.Notify.Persistence.Postgres.Models.RotationType.Custom;
}
var distinctTypes = layers.Select(layer => layer.Type).Distinct().ToList();
return distinctTypes.Count == 1
? distinctTypes[0].ToString().ToLowerInvariant()
: StellaOps.Notify.Persistence.Postgres.Models.RotationType.Custom;
}
private static TimeZoneInfo ResolveTimeZone(string timezone)
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById(timezone);
}
catch
{
return TimeZoneInfo.Utc;
}
}
private static PersistedOnCallScheduleMetadata DeserializeScheduleMetadata(string json)
=> Deserialize<PersistedOnCallScheduleMetadata>(json) ?? new PersistedOnCallScheduleMetadata(null, true, null, null);
private static string GetScheduleExternalId(OnCallScheduleEntity entity, PersistedOnCallScheduleMetadata metadata)
=> string.IsNullOrWhiteSpace(metadata.ExternalId) ? entity.Id.ToString("N") : metadata.ExternalId;
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.Converters.Add(new JsonStringEnumConverter());
return options;
}
private static string Serialize<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
private static T? Deserialize<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
private sealed record PersistedOnCallScheduleMetadata(
string? ExternalId,
bool Enabled,
string? CreatedBy,
string? UpdatedBy);
}