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:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user