Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user