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