Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Escalations/IOnCallSchedule.cs
StellaOps Bot ef6e4b2067
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
Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
2025-11-27 21:45:32 +02:00

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; }
}