using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
///
/// On-call schedule defining who is on-call at any given time.
///
public sealed record NotifyOnCallSchedule
{
[JsonConstructor]
public NotifyOnCallSchedule(
string scheduleId,
string tenantId,
string name,
string timeZone,
ImmutableArray layers,
ImmutableArray overrides,
bool enabled = true,
string? description = null,
ImmutableDictionary? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null)
{
ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId));
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone));
Layers = layers.IsDefault ? ImmutableArray.Empty : layers;
Overrides = overrides.IsDefault ? ImmutableArray.Empty : overrides;
Enabled = enabled;
Description = NotifyValidation.TrimToNull(description);
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
CreatedBy = NotifyValidation.TrimToNull(createdBy);
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
}
public static NotifyOnCallSchedule Create(
string scheduleId,
string tenantId,
string name,
string timeZone,
IEnumerable? layers = null,
IEnumerable? overrides = null,
bool enabled = true,
string? description = null,
IEnumerable>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null)
{
return new NotifyOnCallSchedule(
scheduleId,
tenantId,
name,
timeZone,
layers?.ToImmutableArray() ?? ImmutableArray.Empty,
overrides?.ToImmutableArray() ?? ImmutableArray.Empty,
enabled,
description,
ToImmutableDictionary(metadata),
createdBy,
createdAt,
updatedBy,
updatedAt);
}
public string ScheduleId { get; }
public string TenantId { get; }
public string Name { get; }
///
/// IANA time zone for the schedule (e.g., "America/New_York").
///
public string TimeZone { get; }
///
/// Rotation layers that make up this schedule.
/// Multiple layers are combined to determine final on-call.
///
public ImmutableArray Layers { get; }
///
/// Temporary overrides (e.g., vacation coverage).
///
public ImmutableArray Overrides { get; }
public bool Enabled { get; }
public string? Description { get; }
public ImmutableDictionary Metadata { get; }
public string? CreatedBy { get; }
public DateTimeOffset CreatedAt { get; }
public string? UpdatedBy { get; }
public DateTimeOffset UpdatedAt { get; }
private static ImmutableDictionary? ToImmutableDictionary(IEnumerable>? pairs)
{
if (pairs is null)
{
return null;
}
var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal);
foreach (var (key, value) in pairs)
{
builder[key] = value;
}
return builder.ToImmutable();
}
}
///
/// A layer in an on-call schedule representing a rotation.
///
public sealed record NotifyOnCallLayer
{
[JsonConstructor]
public NotifyOnCallLayer(
string layerId,
string name,
int priority,
NotifyRotationType rotationType,
TimeSpan rotationInterval,
DateTimeOffset rotationStartsAt,
ImmutableArray participants,
NotifyOnCallRestriction? restrictions = null)
{
LayerId = NotifyValidation.EnsureNotNullOrWhiteSpace(layerId, nameof(layerId));
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
Priority = priority;
RotationType = rotationType;
RotationInterval = rotationInterval > TimeSpan.Zero ? rotationInterval : TimeSpan.FromDays(7);
RotationStartsAt = NotifyValidation.EnsureUtc(rotationStartsAt);
Participants = participants.IsDefault ? ImmutableArray.Empty : participants;
Restrictions = restrictions;
}
public static NotifyOnCallLayer Create(
string layerId,
string name,
int priority,
NotifyRotationType rotationType,
TimeSpan rotationInterval,
DateTimeOffset rotationStartsAt,
IEnumerable? participants = null,
NotifyOnCallRestriction? restrictions = null)
{
return new NotifyOnCallLayer(
layerId,
name,
priority,
rotationType,
rotationInterval,
rotationStartsAt,
participants?.ToImmutableArray() ?? ImmutableArray.Empty,
restrictions);
}
public string LayerId { get; }
public string Name { get; }
///
/// Higher priority layers take precedence when determining who is on-call.
///
public int Priority { get; }
public NotifyRotationType RotationType { get; }
///
/// Duration of each rotation (e.g., 1 week).
///
public TimeSpan RotationInterval { get; }
///
/// When the rotation schedule started.
///
public DateTimeOffset RotationStartsAt { get; }
///
/// Participants in the rotation.
///
public ImmutableArray Participants { get; }
///
/// Optional time restrictions for when this layer is active.
///
public NotifyOnCallRestriction? Restrictions { get; }
}
///
/// Participant in an on-call rotation.
///
public sealed record NotifyOnCallParticipant
{
[JsonConstructor]
public NotifyOnCallParticipant(
string userId,
string? name = null,
string? email = null,
string? phone = null,
ImmutableArray contactMethods = default)
{
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
Name = NotifyValidation.TrimToNull(name);
Email = NotifyValidation.TrimToNull(email);
Phone = NotifyValidation.TrimToNull(phone);
ContactMethods = contactMethods.IsDefault ? ImmutableArray.Empty : contactMethods;
}
public static NotifyOnCallParticipant Create(
string userId,
string? name = null,
string? email = null,
string? phone = null,
IEnumerable? contactMethods = null)
{
return new NotifyOnCallParticipant(
userId,
name,
email,
phone,
contactMethods?.ToImmutableArray() ?? ImmutableArray.Empty);
}
public string UserId { get; }
public string? Name { get; }
public string? Email { get; }
public string? Phone { get; }
public ImmutableArray ContactMethods { get; }
}
///
/// Contact method for a participant.
///
public sealed record NotifyContactMethod
{
[JsonConstructor]
public NotifyContactMethod(
NotifyContactMethodType type,
string address,
int priority = 0,
bool enabled = true)
{
Type = type;
Address = NotifyValidation.EnsureNotNullOrWhiteSpace(address, nameof(address));
Priority = priority;
Enabled = enabled;
}
public NotifyContactMethodType Type { get; }
public string Address { get; }
public int Priority { get; }
public bool Enabled { get; }
}
///
/// Type of contact method.
///
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyContactMethodType
{
Email,
Sms,
Phone,
Slack,
Teams,
Webhook,
InAppInbox,
PagerDuty,
OpsGenie
}
///
/// Type of rotation.
///
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyRotationType
{
///
/// Daily rotation.
///
Daily,
///
/// Weekly rotation.
///
Weekly,
///
/// Custom interval rotation.
///
Custom
}
///
/// Time restrictions for when an on-call layer is active.
///
public sealed record NotifyOnCallRestriction
{
[JsonConstructor]
public NotifyOnCallRestriction(
NotifyRestrictionType type,
ImmutableArray timeRanges)
{
Type = type;
TimeRanges = timeRanges.IsDefault ? ImmutableArray.Empty : timeRanges;
}
public static NotifyOnCallRestriction Create(
NotifyRestrictionType type,
IEnumerable? timeRanges = null)
{
return new NotifyOnCallRestriction(
type,
timeRanges?.ToImmutableArray() ?? ImmutableArray.Empty);
}
public NotifyRestrictionType Type { get; }
public ImmutableArray TimeRanges { get; }
}
///
/// Type of restriction.
///
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyRestrictionType
{
///
/// Restrictions apply daily.
///
DailyRestriction,
///
/// Restrictions apply weekly on specific days.
///
WeeklyRestriction
}
///
/// A time range for restrictions.
///
public sealed record NotifyTimeRange
{
[JsonConstructor]
public NotifyTimeRange(
DayOfWeek? dayOfWeek,
TimeOnly startTime,
TimeOnly endTime)
{
DayOfWeek = dayOfWeek;
StartTime = startTime;
EndTime = endTime;
}
///
/// Day of week (null for daily restrictions).
///
public DayOfWeek? DayOfWeek { get; }
public TimeOnly StartTime { get; }
public TimeOnly EndTime { get; }
}
///
/// Temporary override for an on-call schedule.
///
public sealed record NotifyOnCallOverride
{
[JsonConstructor]
public NotifyOnCallOverride(
string overrideId,
string userId,
DateTimeOffset startsAt,
DateTimeOffset endsAt,
string? reason = null,
string? createdBy = null,
DateTimeOffset? createdAt = null)
{
OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId));
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
StartsAt = NotifyValidation.EnsureUtc(startsAt);
EndsAt = NotifyValidation.EnsureUtc(endsAt);
Reason = NotifyValidation.TrimToNull(reason);
CreatedBy = NotifyValidation.TrimToNull(createdBy);
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
if (EndsAt <= StartsAt)
{
throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt));
}
}
public static NotifyOnCallOverride Create(
string overrideId,
string userId,
DateTimeOffset startsAt,
DateTimeOffset endsAt,
string? reason = null,
string? createdBy = null,
DateTimeOffset? createdAt = null)
{
return new NotifyOnCallOverride(
overrideId,
userId,
startsAt,
endsAt,
reason,
createdBy,
createdAt);
}
public string OverrideId { get; }
///
/// User who will be on-call during this override.
///
public string UserId { get; }
public DateTimeOffset StartsAt { get; }
public DateTimeOffset EndsAt { get; }
public string? Reason { get; }
public string? CreatedBy { get; }
public DateTimeOffset CreatedAt { get; }
///
/// Checks if the override is active at the specified time.
///
public bool IsActiveAt(DateTimeOffset timestamp)
=> timestamp >= StartsAt && timestamp < EndsAt;
}
///
/// Result of resolving who is currently on-call.
///
public sealed record NotifyOnCallResolution
{
public NotifyOnCallResolution(
string scheduleId,
DateTimeOffset evaluatedAt,
ImmutableArray onCallUsers,
string? sourceLayer = null,
string? sourceOverride = null)
{
ScheduleId = scheduleId;
EvaluatedAt = evaluatedAt;
OnCallUsers = onCallUsers.IsDefault ? ImmutableArray.Empty : onCallUsers;
SourceLayer = sourceLayer;
SourceOverride = sourceOverride;
}
public string ScheduleId { get; }
public DateTimeOffset EvaluatedAt { get; }
public ImmutableArray OnCallUsers { get; }
///
/// The layer that provided the on-call user (if from rotation).
///
public string? SourceLayer { get; }
///
/// The override that provided the on-call user (if from override).
///
public string? SourceOverride { get; }
}