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
432 lines
11 KiB
C#
432 lines
11 KiB
C#
namespace StellaOps.Notifier.Worker.Escalations;
|
|
|
|
/// <summary>
|
|
/// Manages on-call schedules and determines who is currently on-call.
|
|
/// </summary>
|
|
public interface IOnCallScheduleService
|
|
{
|
|
/// <summary>
|
|
/// Gets a schedule by ID.
|
|
/// </summary>
|
|
Task<OnCallSchedule?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Lists all schedules for a tenant.
|
|
/// </summary>
|
|
Task<IReadOnlyList<OnCallSchedule>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Creates or updates a schedule.
|
|
/// </summary>
|
|
Task<OnCallSchedule> UpsertAsync(OnCallSchedule schedule, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Deletes a schedule.
|
|
/// </summary>
|
|
Task<bool> DeleteAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Gets who is currently on-call for a schedule.
|
|
/// </summary>
|
|
Task<OnCallResolution> GetCurrentOnCallAsync(
|
|
string tenantId,
|
|
string scheduleId,
|
|
DateTimeOffset? asOf = null,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Gets on-call coverage for a time range.
|
|
/// </summary>
|
|
Task<IReadOnlyList<OnCallCoverage>> GetCoverageAsync(
|
|
string tenantId,
|
|
string scheduleId,
|
|
DateTimeOffset from,
|
|
DateTimeOffset to,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Adds an override to a schedule.
|
|
/// </summary>
|
|
Task<OnCallOverride> AddOverrideAsync(
|
|
string tenantId,
|
|
string scheduleId,
|
|
OnCallOverride @override,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Removes an override from a schedule.
|
|
/// </summary>
|
|
Task<bool> RemoveOverrideAsync(
|
|
string tenantId,
|
|
string scheduleId,
|
|
string overrideId,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// On-call schedule defining rotation of responders.
|
|
/// </summary>
|
|
public sealed record OnCallSchedule
|
|
{
|
|
/// <summary>
|
|
/// Unique schedule identifier.
|
|
/// </summary>
|
|
public required string ScheduleId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Tenant this schedule belongs to.
|
|
/// </summary>
|
|
public required string TenantId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Human-readable name.
|
|
/// </summary>
|
|
public required string Name { get; init; }
|
|
|
|
/// <summary>
|
|
/// Description of the schedule.
|
|
/// </summary>
|
|
public string? Description { get; init; }
|
|
|
|
/// <summary>
|
|
/// Timezone for the schedule (IANA format).
|
|
/// </summary>
|
|
public string Timezone { get; init; } = "UTC";
|
|
|
|
/// <summary>
|
|
/// Rotation layers (evaluated in order, first match wins).
|
|
/// </summary>
|
|
public required IReadOnlyList<RotationLayer> Layers { get; init; }
|
|
|
|
/// <summary>
|
|
/// Current overrides to the schedule.
|
|
/// </summary>
|
|
public IReadOnlyList<OnCallOverride> Overrides { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// Fallback user if no one is on-call.
|
|
/// </summary>
|
|
public string? FallbackUserId { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the schedule was created.
|
|
/// </summary>
|
|
public DateTimeOffset CreatedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the schedule was last updated.
|
|
/// </summary>
|
|
public DateTimeOffset UpdatedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether the schedule is enabled.
|
|
/// </summary>
|
|
public bool Enabled { get; init; } = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A rotation layer within an on-call schedule.
|
|
/// </summary>
|
|
public sealed record RotationLayer
|
|
{
|
|
/// <summary>
|
|
/// Layer name.
|
|
/// </summary>
|
|
public required string Name { get; init; }
|
|
|
|
/// <summary>
|
|
/// Rotation type.
|
|
/// </summary>
|
|
public required RotationType Type { get; init; }
|
|
|
|
/// <summary>
|
|
/// Users in the rotation (in order).
|
|
/// </summary>
|
|
public required IReadOnlyList<OnCallUser> Users { get; init; }
|
|
|
|
/// <summary>
|
|
/// When this rotation starts.
|
|
/// </summary>
|
|
public required DateTimeOffset StartTime { get; init; }
|
|
|
|
/// <summary>
|
|
/// Rotation interval (e.g., 1 week for weekly rotation).
|
|
/// </summary>
|
|
public required TimeSpan RotationInterval { get; init; }
|
|
|
|
/// <summary>
|
|
/// Handoff time of day (in schedule timezone).
|
|
/// </summary>
|
|
public TimeOnly HandoffTime { get; init; } = new(9, 0);
|
|
|
|
/// <summary>
|
|
/// Days of week this layer is active (empty = all days).
|
|
/// </summary>
|
|
public IReadOnlyList<DayOfWeek>? ActiveDays { get; init; }
|
|
|
|
/// <summary>
|
|
/// Time restrictions (e.g., only active 9am-5pm).
|
|
/// </summary>
|
|
public OnCallTimeRestriction? TimeRestriction { get; init; }
|
|
|
|
/// <summary>
|
|
/// Layer priority (lower = higher priority).
|
|
/// </summary>
|
|
public int Priority { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Type of rotation.
|
|
/// </summary>
|
|
public enum RotationType
|
|
{
|
|
/// <summary>
|
|
/// Users rotate on a regular interval.
|
|
/// </summary>
|
|
Daily,
|
|
|
|
/// <summary>
|
|
/// Users rotate weekly.
|
|
/// </summary>
|
|
Weekly,
|
|
|
|
/// <summary>
|
|
/// Custom rotation interval.
|
|
/// </summary>
|
|
Custom
|
|
}
|
|
|
|
/// <summary>
|
|
/// A user in an on-call rotation.
|
|
/// </summary>
|
|
public sealed record OnCallUser
|
|
{
|
|
/// <summary>
|
|
/// User identifier.
|
|
/// </summary>
|
|
public required string UserId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Display name.
|
|
/// </summary>
|
|
public string? DisplayName { get; init; }
|
|
|
|
/// <summary>
|
|
/// Email address.
|
|
/// </summary>
|
|
public string? Email { get; init; }
|
|
|
|
/// <summary>
|
|
/// Preferred notification channels.
|
|
/// </summary>
|
|
public IReadOnlyList<string> PreferredChannels { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// Contact methods in priority order.
|
|
/// </summary>
|
|
public IReadOnlyList<ContactMethod> ContactMethods { get; init; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Contact method for a user.
|
|
/// </summary>
|
|
public sealed record ContactMethod
|
|
{
|
|
/// <summary>
|
|
/// Contact type (email, sms, phone, slack, etc.).
|
|
/// </summary>
|
|
public required string Type { get; init; }
|
|
|
|
/// <summary>
|
|
/// Contact address/number.
|
|
/// </summary>
|
|
public required string Address { get; init; }
|
|
|
|
/// <summary>
|
|
/// Label for this contact method.
|
|
/// </summary>
|
|
public string? Label { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether this is verified.
|
|
/// </summary>
|
|
public bool Verified { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Time restriction for a rotation layer.
|
|
/// </summary>
|
|
public sealed record OnCallTimeRestriction
|
|
{
|
|
/// <summary>
|
|
/// Start time of active period.
|
|
/// </summary>
|
|
public required TimeOnly StartTime { get; init; }
|
|
|
|
/// <summary>
|
|
/// End time of active period.
|
|
/// </summary>
|
|
public required TimeOnly EndTime { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether the restriction spans midnight (e.g., 10pm-6am).
|
|
/// </summary>
|
|
public bool SpansMidnight => EndTime < StartTime;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Override to the normal on-call schedule.
|
|
/// </summary>
|
|
public sealed record OnCallOverride
|
|
{
|
|
/// <summary>
|
|
/// Override identifier.
|
|
/// </summary>
|
|
public required string OverrideId { get; init; }
|
|
|
|
/// <summary>
|
|
/// User who will be on-call during this override.
|
|
/// </summary>
|
|
public required string UserId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Display name of the override user.
|
|
/// </summary>
|
|
public string? UserDisplayName { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the override starts.
|
|
/// </summary>
|
|
public required DateTimeOffset StartTime { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the override ends.
|
|
/// </summary>
|
|
public required DateTimeOffset EndTime { get; init; }
|
|
|
|
/// <summary>
|
|
/// Reason for the override.
|
|
/// </summary>
|
|
public string? Reason { get; init; }
|
|
|
|
/// <summary>
|
|
/// Who created the override.
|
|
/// </summary>
|
|
public string? CreatedBy { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the override was created.
|
|
/// </summary>
|
|
public DateTimeOffset CreatedAt { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of on-call resolution.
|
|
/// </summary>
|
|
public sealed record OnCallResolution
|
|
{
|
|
/// <summary>
|
|
/// Whether someone is on-call.
|
|
/// </summary>
|
|
public required bool HasOnCall { get; init; }
|
|
|
|
/// <summary>
|
|
/// The on-call user (if any).
|
|
/// </summary>
|
|
public OnCallUser? OnCallUser { get; init; }
|
|
|
|
/// <summary>
|
|
/// Which layer resolved the on-call.
|
|
/// </summary>
|
|
public string? ResolvedFromLayer { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether this is from an override.
|
|
/// </summary>
|
|
public bool IsOverride { get; init; }
|
|
|
|
/// <summary>
|
|
/// Override details if applicable.
|
|
/// </summary>
|
|
public OnCallOverride? Override { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether this is the fallback user.
|
|
/// </summary>
|
|
public bool IsFallback { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the current on-call shift ends.
|
|
/// </summary>
|
|
public DateTimeOffset? ShiftEndsAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// The time this resolution was calculated for.
|
|
/// </summary>
|
|
public DateTimeOffset AsOf { get; init; }
|
|
|
|
public static OnCallResolution NoOneOnCall(DateTimeOffset asOf) =>
|
|
new() { HasOnCall = false, AsOf = asOf };
|
|
|
|
public static OnCallResolution FromUser(OnCallUser user, string layer, DateTimeOffset asOf, DateTimeOffset? shiftEnds = null) =>
|
|
new()
|
|
{
|
|
HasOnCall = true,
|
|
OnCallUser = user,
|
|
ResolvedFromLayer = layer,
|
|
AsOf = asOf,
|
|
ShiftEndsAt = shiftEnds
|
|
};
|
|
|
|
public static OnCallResolution FromOverride(OnCallUser user, OnCallOverride @override, DateTimeOffset asOf) =>
|
|
new()
|
|
{
|
|
HasOnCall = true,
|
|
OnCallUser = user,
|
|
IsOverride = true,
|
|
Override = @override,
|
|
AsOf = asOf,
|
|
ShiftEndsAt = @override.EndTime
|
|
};
|
|
|
|
public static OnCallResolution FromFallback(OnCallUser user, DateTimeOffset asOf) =>
|
|
new()
|
|
{
|
|
HasOnCall = true,
|
|
OnCallUser = user,
|
|
IsFallback = true,
|
|
AsOf = asOf
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// On-call coverage for a time period.
|
|
/// </summary>
|
|
public sealed record OnCallCoverage
|
|
{
|
|
/// <summary>
|
|
/// Start of this coverage period.
|
|
/// </summary>
|
|
public required DateTimeOffset From { get; init; }
|
|
|
|
/// <summary>
|
|
/// End of this coverage period.
|
|
/// </summary>
|
|
public required DateTimeOffset To { get; init; }
|
|
|
|
/// <summary>
|
|
/// User on-call during this period.
|
|
/// </summary>
|
|
public required OnCallUser User { get; init; }
|
|
|
|
/// <summary>
|
|
/// Layer providing coverage.
|
|
/// </summary>
|
|
public string? Layer { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether this is from an override.
|
|
/// </summary>
|
|
public bool IsOverride { get; init; }
|
|
}
|